modewise_pipeline/
spec.rs1use std::collections::BTreeMap;
2
3use serde::Deserialize;
4use serde_json::{Map, Value};
5
6use crate::{ArtifactKind, PipelineError};
7
8#[derive(Debug, Clone, Deserialize)]
10#[serde(deny_unknown_fields)]
11pub struct PipelineSpec {
12 pub pipeline: PipelineHeader,
13 #[serde(default, rename = "step")]
14 pub steps: Vec<StepSpec>,
15}
16
17#[derive(Debug, Clone, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct PipelineHeader {
20 pub name: String,
21 #[serde(default)]
24 pub inputs: InputDecl,
25}
26
27#[derive(Debug, Clone, Deserialize)]
28#[serde(untagged)]
29pub enum InputDecl {
30 Series(Vec<String>),
31 Typed(BTreeMap<String, ArtifactKind>),
32}
33
34impl Default for InputDecl {
35 fn default() -> Self {
36 Self::Series(Vec::new())
37 }
38}
39
40impl InputDecl {
41 pub fn kinds(&self) -> Vec<(String, ArtifactKind)> {
43 match self {
44 Self::Series(names) => names
45 .iter()
46 .map(|n| (n.clone(), ArtifactKind::Series))
47 .collect(),
48 Self::Typed(map) => map.iter().map(|(n, k)| (n.clone(), *k)).collect(),
49 }
50 }
51}
52
53#[derive(Debug, Clone, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct StepSpec {
56 pub id: String,
57 pub op: String,
58 #[serde(default)]
59 pub input: InputSpec,
60 #[serde(default)]
61 pub params: Map<String, Value>,
62 #[serde(default = "keep_by_default")]
66 pub keep: bool,
67}
68
69fn keep_by_default() -> bool {
70 true
71}
72
73#[derive(Debug, Clone, Deserialize)]
76#[serde(untagged)]
77pub enum InputSpec {
78 Single(String),
79 Named(BTreeMap<String, String>),
80}
81
82impl Default for InputSpec {
83 fn default() -> Self {
84 Self::Named(BTreeMap::new())
85 }
86}
87
88impl PipelineSpec {
89 pub fn from_toml(text: &str) -> Result<Self, PipelineError> {
90 toml::from_str(text).map_err(|e| PipelineError::Spec(e.to_string()))
91 }
92
93 pub fn from_json(text: &str) -> Result<Self, PipelineError> {
94 serde_json::from_str(text).map_err(|e| PipelineError::Spec(e.to_string()))
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 #[test]
103 fn parses_single_and_named_inputs() {
104 let spec = PipelineSpec::from_toml(
105 r#"
106 [pipeline]
107 name = "demo"
108 inputs = ["bold"]
109
110 [[step]]
111 id = "modes"
112 op = "mvmd"
113 input = "bold"
114 params = { num_modes = 4, alpha = 1000.0 }
115
116 [[step]]
117 id = "mix"
118 op = "combine"
119 input = { left = "modes", right = "bold" }
120 "#,
121 )
122 .unwrap();
123 assert_eq!(
124 spec.pipeline.inputs.kinds(),
125 [("bold".to_string(), ArtifactKind::Series)]
126 );
127 assert!(matches!(&spec.steps[0].input, InputSpec::Single(r) if r == "bold"));
128 assert_eq!(spec.steps[0].params["num_modes"], 4);
129 assert!(matches!(&spec.steps[1].input, InputSpec::Named(m) if m.len() == 2));
130 }
131
132 #[test]
133 fn parses_json() {
134 let spec = PipelineSpec::from_json(
135 r#"{"pipeline": {"name": "j", "inputs": ["x"]},
136 "step": [{"id": "a", "op": "scale", "input": "x", "params": {"factor": 2}}]}"#,
137 )
138 .unwrap();
139 assert_eq!(spec.steps[0].params["factor"], 2);
140 }
141
142 #[test]
143 fn parses_typed_inputs() {
144 let spec = PipelineSpec::from_toml(
145 "[pipeline]\nname = \"t\"\ninputs = { bold = \"series\", trials = \"events\" }\n",
146 )
147 .unwrap();
148 assert_eq!(
149 spec.pipeline.inputs.kinds(),
150 [
151 ("bold".to_string(), ArtifactKind::Series),
152 ("trials".to_string(), ArtifactKind::Events)
153 ]
154 );
155 assert!(
156 PipelineSpec::from_toml("[pipeline]\nname = \"t\"\ninputs = { bold = \"sound\" }\n")
157 .is_err()
158 );
159 }
160
161 #[test]
162 fn rejects_invalid_json() {
163 assert!(matches!(
164 PipelineSpec::from_json("{not json"),
165 Err(PipelineError::Spec(_))
166 ));
167 }
168
169 #[test]
170 fn rejects_unknown_fields() {
171 let err = PipelineSpec::from_toml("[pipeline]\nname = \"x\"\ntypo = 1\n").unwrap_err();
172 assert!(matches!(err, PipelineError::Spec(_)));
173 }
174}