Skip to main content

panopticon_core/
param.rs

1use crate::imports::*;
2
3/// A parameter value supplied to an operation input at draft time.
4///
5/// Operations declare their inputs in [`OperationMetadata`]; callers bind
6/// each declared input to a `Param` through the [`params!`] macro when
7/// adding a step with [`Pipeline::step`]. A `Param` is either a literal
8/// (resolved immediately from the embedded [`Value`]) or a reference that
9/// looks up state from the runtime [`Store`] when the step runs.
10///
11/// Most code constructs `Param`s through the [`params!`] macro rather than
12/// the variants directly — the macro picks the right variant from the Rust
13/// literal or reference syntax it is given.
14///
15/// ```no_run
16/// use panopticon_core::prelude::*;
17///
18/// let mut pipe = Pipeline::default();
19/// pipe.var("target", "world")?;
20/// pipe.step::<SetVar>(
21///     "greet",
22///     params!(
23///         "name" => "greeting",
24///         "value" => Param::template(vec![
25///             Param::literal("hello, "),
26///             Param::reference("target"),
27///         ]),
28///     ),
29/// )?;
30/// # Ok::<(), Box<dyn std::error::Error>>(())
31/// ```
32#[derive(Debug, Clone)]
33pub enum Param {
34    /// A scalar value supplied directly, with no store lookup.
35    Literal(Value),
36    /// A reference to a store entry by dotted path (e.g. `"var_name"` or
37    /// `"step_name.output_name"`). Resolved against the runtime store
38    /// when the step executes.
39    Ref(String),
40    /// A string template whose parts (literals or references) are resolved
41    /// and concatenated into a single `Text` value at runtime. Every part
42    /// must resolve to a scalar — nested arrays or maps produce
43    /// [`OperationError::InvalidTemplatePart`].
44    Template(Vec<Param>),
45    /// An array of parameters. Each element is resolved independently and
46    /// the result is wrapped in a [`StoreEntry::Array`].
47    Array(Vec<Param>),
48    /// A map of parameters keyed by name. Each value is resolved
49    /// independently and the result is wrapped in a [`StoreEntry::Map`].
50    Map(HashMap<String, Param>),
51}
52
53impl Param {
54    /// Constructs a [`Param::Literal`] from anything convertible into a
55    /// [`Value`].
56    pub fn literal(value: impl Into<Value>) -> Self {
57        Param::Literal(value.into())
58    }
59    /// Constructs a [`Param::Ref`] pointing at a dotted store path.
60    pub fn reference(path: impl Into<String>) -> Self {
61        Param::Ref(path.into())
62    }
63    /// Constructs a [`Param::Template`] from an ordered list of parts.
64    pub fn template(parts: Vec<Param>) -> Self {
65        Param::Template(parts)
66    }
67    /// Constructs a [`Param::Array`] from an ordered list of parameters.
68    pub fn array(items: Vec<Param>) -> Self {
69        Param::Array(items)
70    }
71    /// Constructs a [`Param::Map`] from a map of named parameters.
72    pub fn map(entries: HashMap<String, Param>) -> Self {
73        Param::Map(entries)
74    }
75
76    // Resolution to actual Values as StoreEntry.
77    pub(crate) fn resolve(&self, store: &Store<StoreEntry>) -> Result<StoreEntry, OperationError> {
78        match self {
79            Param::Literal(v) => {
80                let ty = v.get_type();
81                Ok(StoreEntry::Var {
82                    value: v.clone(),
83                    ty,
84                })
85            }
86
87            Param::Ref(path) => {
88                store
89                    .get(path)
90                    .cloned()
91                    .map_err(|_| OperationError::ReferenceNotFound {
92                        reference: path.clone(),
93                    })
94            }
95
96            Param::Template(parts) => {
97                let mut result = String::new();
98                for part in parts {
99                    let resolved = part.resolve(store)?;
100                    match resolved {
101                        StoreEntry::Var { value, .. } => {
102                            result.push_str(&value.to_string());
103                        }
104                        _ => {
105                            return Err(OperationError::InvalidTemplatePart);
106                        }
107                    }
108                }
109                Ok(StoreEntry::Var {
110                    ty: Type::Text,
111                    value: Value::Text(result),
112                })
113            }
114
115            Param::Array(items) => {
116                let resolved: Result<Vec<StoreEntry>, _> =
117                    items.iter().map(|item| item.resolve(store)).collect();
118                Ok(StoreEntry::Array(resolved?))
119            }
120
121            Param::Map(entries) => {
122                let resolved: Result<HashMap<String, StoreEntry>, _> = entries
123                    .iter()
124                    .map(|(k, v)| v.resolve(store).map(|resolved| (k.clone(), resolved)))
125                    .collect();
126                Ok(StoreEntry::Map(resolved?))
127            }
128        }
129    }
130
131    /// Returns every dotted store path referenced by this parameter,
132    /// walking into nested templates, arrays, and maps. Used by draft-time
133    /// validation to catch forward and unresolved references.
134    pub fn get_references(&self) -> Vec<String> {
135        match self {
136            Param::Literal(_) => vec![],
137            Param::Ref(path) => vec![path.clone()],
138            Param::Template(parts) => parts.iter().flat_map(|p| p.get_references()).collect(),
139            Param::Array(items) => items.iter().flat_map(|i| i.get_references()).collect(),
140            Param::Map(entries) => entries.values().flat_map(|v| v.get_references()).collect(),
141        }
142    }
143}
144
145impl<V: Into<Value>> From<V> for Param {
146    fn from(v: V) -> Self {
147        // In most cases we'd want to resolve these down into
148        Param::Literal(v.into())
149    }
150}
151
152/// A named collection of [`Param`]s bound to an operation's declared inputs.
153///
154/// Callers build `Parameters` through the [`params!`] macro when adding a
155/// step or a return block. Draft-time validation uses [`Param::get_references`]
156/// to walk every contained parameter and check that all references resolve;
157/// at runtime, resolution turns each `Param` into a [`StoreEntry`] keyed by
158/// `"{step_name}.{input_name}"` in the runtime store.
159#[derive(Debug, Clone)]
160pub struct Parameters(HashMap<String, Param>);
161
162impl Parameters {
163    /// Wraps a ready-built map of named parameters. Prefer the [`params!`]
164    /// macro in user code.
165    pub fn new(map: HashMap<String, Param>) -> Self {
166        Parameters(map)
167    }
168
169    /// Returns the parameter bound to the given input name, or `None` if
170    /// the name is not bound.
171    pub fn get(&self, key: &str) -> Option<&Param> {
172        self.0.get(key)
173    }
174
175    /// Iterates over the bound input names.
176    pub fn keys(&self) -> impl Iterator<Item = &String> {
177        self.0.keys()
178    }
179
180    /// Iterates over the bound [`Param`]s.
181    pub fn values(&self) -> impl Iterator<Item = &Param> {
182        self.0.values()
183    }
184
185    /// Iterates over `(input_name, param)` pairs.
186    pub fn iter(&self) -> impl Iterator<Item = (&String, &Param)> {
187        self.0.iter()
188    }
189
190    fn resolve_all(
191        &self,
192        store: &Store<StoreEntry>,
193    ) -> Result<HashMap<String, StoreEntry>, OperationError> {
194        self.0
195            .iter()
196            .map(|(k, v)| v.resolve(store).map(|resolved| (k.clone(), resolved)))
197            .collect()
198    }
199
200    /// Resolves every parameter against `store` and writes each result back
201    /// into the same store under `"{prefix_part}.{input_name}"`. Returns
202    /// the prefix for use by the caller.
203    pub fn resolve_in_store(
204        &self,
205        prefix_part: impl Into<String>,
206        store: &mut Store<StoreEntry>,
207    ) -> Result<String, OperationError> {
208        let resolved = self.resolve_all(store)?;
209        let prefix: String = prefix_part.into();
210
211        for (key, value) in resolved {
212            store
213                .insert(format!("{}.{}", prefix, key), value)
214                .map_err(|e| OperationError::ParameterResolutionFailed {
215                    parameter: key.clone(),
216                    reason: e.to_string(),
217                })?;
218        }
219        Ok(prefix)
220    }
221
222    /// Resolves every parameter against `src` and writes each result into
223    /// `dst` under `"{prefix_part}.{input_name}"`. Used when the source
224    /// and destination stores differ — for example, when resolving a
225    /// return block's parameters against the pipeline's variables store
226    /// and placing the results in a fresh returns store.
227    pub fn resolve_to_store(
228        &self,
229        prefix_part: impl Into<String>,
230        src: &Store<StoreEntry>,
231        dst: &mut Store<StoreEntry>,
232    ) -> Result<(), OperationError> {
233        let resolved = self.resolve_all(src)?;
234        let prefix: String = prefix_part.into();
235
236        for (key, value) in resolved {
237            dst.insert(format!("{}.{}", prefix, key), value)
238                .map_err(|e| OperationError::ParameterResolutionFailed {
239                    parameter: key.clone(),
240                    reason: e.to_string(),
241                })?;
242        }
243        Ok(())
244    }
245}
246
247/// Builds a [`Parameters`] map from a list of `"name" => value` pairs.
248///
249/// Each value is passed through `Into::<Param>::into`, so Rust literals
250/// (`"text"`, `42i64`, `true`) become [`Param::Literal`] automatically.
251/// For references, templates, arrays, and maps, use the constructors on
252/// [`Param`] directly.
253///
254/// ```no_run
255/// use panopticon_core::prelude::*;
256///
257/// let mut pipe = Pipeline::default();
258/// pipe.var("workspace_id", "ws-001")?;
259/// pipe.step::<SetVar>(
260///     "configure",
261///     params!(
262///         "name" => "workspace",
263///         "value" => Param::reference("workspace_id"),
264///     ),
265/// )?;
266/// # Ok::<(), Box<dyn std::error::Error>>(())
267/// ```
268#[macro_export]
269macro_rules! params {
270    () => {
271        $crate::extend::Parameters::new(
272            ::std::collections::HashMap::<String, $crate::prelude::Param>::new(),
273        )
274    };
275    ($($key:expr => $value:expr),+ $(,)?) => {{
276        let mut map = ::std::collections::HashMap::<String, $crate::prelude::Param>::new();
277        $(
278            map.insert($key.into(), $value.into());
279        )+
280        $crate::extend::Parameters::new(map)
281    }};
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn scalar_literals() {
290        let mut store = Store::<StoreEntry>::new();
291        store.define_var("unused", "placeholder").unwrap();
292
293        let p = params!(
294            "name" => "number",
295            "value" => 42i64,
296            "flag" => true,
297        );
298
299        let resolved = p.get("name").unwrap().resolve(&store).unwrap();
300        assert_eq!(
301            resolved,
302            StoreEntry::Var {
303                value: Value::Text("number".into()),
304                ty: Type::Text,
305            }
306        );
307
308        let resolved = p.get("value").unwrap().resolve(&store).unwrap();
309        assert_eq!(
310            resolved,
311            StoreEntry::Var {
312                value: Value::Integer(42),
313                ty: Type::Integer,
314            }
315        );
316    }
317
318    #[test]
319    fn reference_resolution() {
320        let mut store = Store::<StoreEntry>::new();
321        store.define_var("workspace_id", "ws-abc-123").unwrap();
322
323        let p = params!(
324            "workspace" => Param::reference("workspace_id"),
325        );
326
327        let resolved = p.get("workspace").unwrap().resolve(&store).unwrap();
328        assert_eq!(
329            resolved,
330            StoreEntry::Var {
331                value: Value::Text("ws-abc-123".into()),
332                ty: Type::Text,
333            }
334        );
335    }
336
337    #[test]
338    fn reference_missing() {
339        let store = Store::<StoreEntry>::new();
340
341        let p = Param::reference("nonexistent");
342        assert!(p.resolve(&store).is_err());
343    }
344
345    #[test]
346    fn template_with_refs() {
347        let mut store = Store::<StoreEntry>::new();
348        store.define_var("days", 7i64).unwrap();
349
350        let p = Param::template(vec![
351            Param::literal("SigninLogs | ago("),
352            Param::reference("days"),
353            Param::literal("d)"),
354        ]);
355
356        let resolved = p.resolve(&store).unwrap();
357        assert_eq!(
358            resolved,
359            StoreEntry::Var {
360                value: Value::Text("SigninLogs | ago(7d)".into()),
361                ty: Type::Text,
362            }
363        );
364    }
365
366    #[test]
367    fn array_of_literals_and_refs() {
368        let mut store = Store::<StoreEntry>::new();
369        store.define_var("auto_label", "auto-triaged").unwrap();
370
371        let p = Param::array(vec![
372            Param::literal("high-priority"),
373            Param::reference("auto_label"),
374        ]);
375
376        let resolved = p.resolve(&store).unwrap();
377        match resolved {
378            StoreEntry::Array(items) => {
379                assert_eq!(items.len(), 2);
380                assert_eq!(
381                    items[0].get_value().unwrap(),
382                    &Value::Text("high-priority".into())
383                );
384                assert_eq!(
385                    items[1].get_value().unwrap(),
386                    &Value::Text("auto-triaged".into())
387                );
388            }
389            _ => panic!("Expected Array"),
390        }
391    }
392
393    #[test]
394    fn inline_map() {
395        let mut store = Store::<StoreEntry>::new();
396        store.define_var("ws_id", "ws-001").unwrap();
397
398        let p = params!(
399            "severity" => "High",
400            "config" => Param::map(HashMap::from([
401                ("workspace".into(), Param::reference("ws_id")),
402                ("timeout".into(), Param::literal(30i64)),
403            ])),
404        );
405
406        let resolved = p.get("config").unwrap().resolve(&store).unwrap();
407        match resolved {
408            StoreEntry::Map(entries) => {
409                assert_eq!(
410                    entries.get("workspace").unwrap().get_value().unwrap(),
411                    &Value::Text("ws-001".into())
412                );
413                assert_eq!(
414                    entries.get("timeout").unwrap().get_value().unwrap(),
415                    &Value::Integer(30)
416                );
417            }
418            _ => panic!("Expected Map"),
419        }
420    }
421
422    #[test]
423    fn nested_map_with_array_and_template() {
424        let mut store = Store::<StoreEntry>::new();
425        store.define_var("tenant", "contoso.com").unwrap();
426        store.define_var("days", 30i64).unwrap();
427
428        let p = Param::map(HashMap::from([
429            ("workspace".into(), Param::literal("ws-sentinel-01")),
430            (
431                "scopes".into(),
432                Param::array(vec![
433                    Param::literal("https://graph.microsoft.com/.default"),
434                    Param::literal("https://management.azure.com/.default"),
435                ]),
436            ),
437            (
438                "query".into(),
439                Param::template(vec![
440                    Param::literal("SigninLogs | where TenantId == '"),
441                    Param::reference("tenant"),
442                    Param::literal("' | ago("),
443                    Param::reference("days"),
444                    Param::literal("d)"),
445                ]),
446            ),
447        ]));
448
449        let resolved = p.resolve(&store).unwrap();
450        match resolved {
451            StoreEntry::Map(entries) => {
452                assert_eq!(
453                    entries.get("workspace").unwrap().get_value().unwrap(),
454                    &Value::Text("ws-sentinel-01".into())
455                );
456
457                match entries.get("scopes").unwrap() {
458                    StoreEntry::Array(items) => assert_eq!(items.len(), 2),
459                    _ => panic!("Expected Array for scopes"),
460                }
461
462                assert_eq!(
463                    entries.get("query").unwrap().get_value().unwrap(),
464                    &Value::Text("SigninLogs | where TenantId == 'contoso.com' | ago(30d)".into())
465                );
466            }
467            _ => panic!("Expected Map"),
468        }
469    }
470
471    #[test]
472    fn get_references_extracts_all_refs() {
473        let p = Param::map(HashMap::from([
474            ("literal".into(), Param::literal("ignored")),
475            ("ref".into(), Param::reference("ws_id")),
476            (
477                "nested".into(),
478                Param::array(vec![
479                    Param::reference("tenant"),
480                    Param::template(vec![Param::literal("prefix_"), Param::reference("suffix")]),
481                ]),
482            ),
483        ]));
484
485        let mut refs = p.get_references();
486        refs.sort();
487        assert_eq!(refs, vec!["suffix", "tenant", "ws_id"]);
488    }
489}