1use serde::{Deserialize, Serialize};
18use std::collections::BTreeMap;
19#[cfg(feature = "ts")]
20use ts_rs::TS;
21
22use crate::pipeline::PipelineSettings;
23
24#[cfg_attr(feature = "ts", derive(TS))]
37#[cfg_attr(
38 feature = "ts",
39 ts(
40 export,
41 export_to = "../../../../packages/@bnto/nodes/src/generated/definitionTypes/"
42 )
43)]
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
45#[serde(rename_all = "camelCase")]
46pub struct Definition {
47 pub id: String,
48 #[serde(rename = "type")]
49 #[cfg_attr(feature = "ts", ts(rename = "type"))]
50 pub node_type: String,
51 pub version: String,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 #[cfg_attr(feature = "ts", ts(optional))]
54 pub parent_id: Option<String>,
55 pub name: String,
56 pub position: Position,
57 pub metadata: Metadata,
58 #[serde(default = "default_parameters")]
59 #[cfg_attr(feature = "ts", ts(type = "Record<string, unknown>"))]
60 pub parameters: serde_json::Value,
61 pub input_ports: Vec<Port>,
62 pub output_ports: Vec<Port>,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 #[cfg_attr(feature = "ts", ts(optional))]
65 pub nodes: Option<Vec<Definition>>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 #[cfg_attr(feature = "ts", ts(optional))]
68 pub edges: Option<Vec<Edge>>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 #[cfg_attr(feature = "ts", ts(optional))]
71 pub settings: Option<PipelineSettings>,
72 #[serde(default, skip_serializing_if = "Vec::is_empty")]
75 pub requires: Vec<crate::Dependency>,
76 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
79 pub fields: BTreeMap<String, crate::FieldDef>,
80}
81
82#[cfg_attr(feature = "ts", derive(TS))]
84#[cfg_attr(
85 feature = "ts",
86 ts(
87 export,
88 export_to = "../../../../packages/@bnto/nodes/src/generated/definitionTypes/"
89 )
90)]
91#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
92pub struct Position {
93 pub x: f64,
94 pub y: f64,
95}
96
97#[cfg_attr(feature = "ts", derive(TS))]
100#[cfg_attr(
101 feature = "ts",
102 ts(
103 export,
104 export_to = "../../../../packages/@bnto/nodes/src/generated/definitionTypes/"
105 )
106)]
107#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
108#[serde(rename_all = "camelCase")]
109pub struct Metadata {
110 #[serde(skip_serializing_if = "Option::is_none")]
111 #[cfg_attr(feature = "ts", ts(optional))]
112 pub description: Option<String>,
113 #[serde(skip_serializing_if = "Option::is_none")]
114 #[cfg_attr(feature = "ts", ts(optional))]
115 pub created_at: Option<String>,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 #[cfg_attr(feature = "ts", ts(optional))]
118 pub updated_at: Option<String>,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 #[cfg_attr(feature = "ts", ts(optional))]
121 pub category: Option<String>,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 #[cfg_attr(feature = "ts", ts(optional))]
124 pub tags: Option<Vec<String>>,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 #[cfg_attr(feature = "ts", ts(optional, type = "Record<string, string>"))]
127 pub custom_data: Option<BTreeMap<String, String>>,
128}
129
130#[cfg_attr(feature = "ts", derive(TS))]
133#[cfg_attr(
134 feature = "ts",
135 ts(
136 export,
137 export_to = "../../../../packages/@bnto/nodes/src/generated/definitionTypes/"
138 )
139)]
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
141pub struct Port {
142 pub id: String,
143 pub name: String,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 #[cfg_attr(feature = "ts", ts(optional))]
146 pub handle: Option<String>,
147}
148
149#[cfg_attr(feature = "ts", derive(TS))]
152#[cfg_attr(
153 feature = "ts",
154 ts(
155 export,
156 export_to = "../../../../packages/@bnto/nodes/src/generated/definitionTypes/"
157 )
158)]
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
160#[serde(rename_all = "camelCase")]
161pub struct Edge {
162 pub id: String,
163 pub source: String,
164 pub target: String,
165 #[serde(skip_serializing_if = "Option::is_none")]
166 #[cfg_attr(feature = "ts", ts(optional))]
167 pub source_handle: Option<String>,
168 #[serde(skip_serializing_if = "Option::is_none")]
169 #[cfg_attr(feature = "ts", ts(optional))]
170 pub target_handle: Option<String>,
171}
172
173pub(crate) fn default_parameters() -> serde_json::Value {
176 serde_json::Value::Object(serde_json::Map::new())
177}
178
179#[cfg(test)]
184mod tests {
185 use super::*;
186 use serde_json::json;
187
188 fn minimal_leaf(id: &str, node_type: &str) -> Definition {
189 Definition {
190 id: id.to_string(),
191 node_type: node_type.to_string(),
192 version: "1.0.0".to_string(),
193 parent_id: None,
194 name: "Test".to_string(),
195 position: Position { x: 0.0, y: 0.0 },
196 metadata: Metadata::default(),
197 parameters: default_parameters(),
198 input_ports: vec![],
199 output_ports: vec![],
200 nodes: None,
201 edges: None,
202 settings: None,
203 requires: Vec::new(),
204 fields: BTreeMap::new(),
205 }
206 }
207
208 #[test]
211 fn serializes_keys_in_camel_case() {
212 let def = Definition {
213 parent_id: Some("parent".to_string()),
214 ..minimal_leaf("n1", "image-compress")
215 };
216 let json = serde_json::to_value(&def).unwrap();
217 assert!(json.get("parentId").is_some(), "parent_id -> parentId");
218 assert!(
219 json.get("inputPorts").is_some(),
220 "input_ports -> inputPorts"
221 );
222 assert!(
223 json.get("outputPorts").is_some(),
224 "output_ports -> outputPorts"
225 );
226 assert!(json.get("type").is_some(), "node_type -> type");
227 }
228
229 #[test]
230 fn omits_none_fields_on_serialization() {
231 let def = minimal_leaf("n1", "image-compress");
232 let json = serde_json::to_value(&def).unwrap();
233 assert!(json.get("parentId").is_none());
234 assert!(json.get("nodes").is_none());
235 assert!(json.get("edges").is_none());
236 assert!(json.get("settings").is_none());
237 }
238
239 #[test]
240 fn emits_empty_port_arrays_rather_than_omitting() {
241 let def = minimal_leaf("n1", "image-compress");
242 let json = serde_json::to_value(&def).unwrap();
243 assert_eq!(json["inputPorts"], json!([]));
244 assert_eq!(json["outputPorts"], json!([]));
245 }
246
247 #[test]
248 fn empty_metadata_serializes_to_empty_object() {
249 let meta = Metadata::default();
250 let json = serde_json::to_value(&meta).unwrap();
251 assert_eq!(json, json!({}));
252 }
253
254 #[test]
255 fn metadata_omits_none_fields() {
256 let meta = Metadata {
257 description: Some("hello".to_string()),
258 ..Default::default()
259 };
260 let json = serde_json::to_value(&meta).unwrap();
261 assert_eq!(json, json!({ "description": "hello" }));
262 }
263
264 #[test]
265 fn metadata_custom_data_serializes_as_nested_object() {
266 let mut custom = BTreeMap::new();
267 custom.insert("author".to_string(), "ryan".to_string());
268 let meta = Metadata {
269 custom_data: Some(custom),
270 ..Default::default()
271 };
272 let json = serde_json::to_value(&meta).unwrap();
273 assert_eq!(json, json!({ "customData": { "author": "ryan" } }));
274 }
275
276 #[test]
277 fn position_round_trips() {
278 let pos = Position { x: 12.5, y: -4.0 };
279 let json = serde_json::to_string(&pos).unwrap();
280 let back: Position = serde_json::from_str(&json).unwrap();
281 assert_eq!(pos, back);
282 }
283
284 #[test]
285 fn edge_with_handles_uses_camel_case() {
286 let edge = Edge {
287 id: "e1".to_string(),
288 source: "a".to_string(),
289 target: "b".to_string(),
290 source_handle: Some("out".to_string()),
291 target_handle: Some("in".to_string()),
292 };
293 let json = serde_json::to_value(&edge).unwrap();
294 assert_eq!(
295 json,
296 json!({
297 "id": "e1",
298 "source": "a",
299 "target": "b",
300 "sourceHandle": "out",
301 "targetHandle": "in",
302 })
303 );
304 }
305
306 #[test]
307 fn edge_without_handles_omits_them() {
308 let edge = Edge {
309 id: "e1".to_string(),
310 source: "a".to_string(),
311 target: "b".to_string(),
312 source_handle: None,
313 target_handle: None,
314 };
315 let json = serde_json::to_value(&edge).unwrap();
316 assert!(json.get("sourceHandle").is_none());
317 assert!(json.get("targetHandle").is_none());
318 }
319
320 #[test]
321 fn port_with_handle_round_trips() {
322 let port = Port {
323 id: "p1".to_string(),
324 name: "data".to_string(),
325 handle: Some("then".to_string()),
326 };
327 let json = serde_json::to_string(&port).unwrap();
328 let back: Port = serde_json::from_str(&json).unwrap();
329 assert_eq!(port, back);
330 }
331
332 #[test]
335 fn deserializes_real_compress_images_recipe() {
336 let raw = include_str!("../../bnto/tests/fixtures/explicit/compress-images.bnto.json");
337 let def: Definition = serde_json::from_str(raw).expect("deserialization");
338
339 assert_eq!(def.id, "compress-images");
340 assert_eq!(def.node_type, "group");
341 assert_eq!(def.name, "Compress Images");
342 assert_eq!(
343 def.metadata.description.as_deref(),
344 Some("Accepts image files and compresses each one.")
345 );
346
347 let children = def.nodes.as_ref().expect("group has nodes");
348 assert_eq!(children.len(), 3);
349
350 let edges = def.edges.as_ref().expect("group has edges");
351 assert_eq!(edges.len(), 2);
352 assert_eq!(edges[0].source, "input");
353 assert_eq!(edges[0].target, "compress-loop");
354 }
355
356 #[test]
357 fn round_trip_preserves_shape_for_all_recipe_fixtures() {
358 let fixtures = [
365 include_str!("../../bnto/tests/fixtures/explicit/compress-images.bnto.json"),
366 include_str!("../../bnto/tests/fixtures/explicit/optimize-images-for-web.bnto.json"),
367 include_str!("../../bnto/tests/fixtures/explicit/merge-csv.bnto.json"),
368 include_str!("../../bnto/tests/fixtures/explicit/rename-csv-columns.bnto.json"),
369 include_str!("../../bnto/tests/fixtures/explicit/rename-files.bnto.json"),
370 include_str!("../../bnto/tests/fixtures/explicit/strip-exif.bnto.json"),
371 include_str!("../../bnto/tests/fixtures/explicit/svg-to-png.bnto.json"),
372 include_str!("../../bnto/tests/fixtures/explicit/watermark-images.bnto.json"),
373 ];
374
375 for (i, raw) in fixtures.iter().enumerate() {
376 let first: Definition =
377 serde_json::from_str(raw).expect("fixture parses into Definition");
378 let reemitted = serde_json::to_string(&first).expect("serializes back");
379 let second: Definition =
380 serde_json::from_str(&reemitted).expect("re-emitted JSON parses");
381 assert_eq!(first, second, "fixture {} lost data through round-trip", i);
382 }
383 }
384
385 #[test]
386 fn container_node_preserves_nested_children() {
387 let child = minimal_leaf("child", "image-compress");
388 let container = Definition {
389 nodes: Some(vec![child.clone()]),
390 edges: Some(vec![]),
391 ..minimal_leaf("parent", "loop")
392 };
393
394 let json = serde_json::to_string(&container).unwrap();
395 let back: Definition = serde_json::from_str(&json).unwrap();
396 assert_eq!(back.nodes.as_ref().unwrap().len(), 1);
397 assert_eq!(back.nodes.as_ref().unwrap()[0], child);
398 assert_eq!(back.edges.as_ref().unwrap().len(), 0);
399 }
400
401 #[test]
402 fn missing_parameters_defaults_to_empty_object() {
403 let raw = r#"{
406 "id": "n1",
407 "type": "image-compress",
408 "version": "1.0.0",
409 "name": "Test",
410 "position": { "x": 0, "y": 0 },
411 "metadata": {},
412 "inputPorts": [],
413 "outputPorts": []
414 }"#;
415 let def: Definition = serde_json::from_str(raw).expect("accepts missing parameters");
416 assert_eq!(def.parameters, json!({}));
417 }
418}