Skip to main content

bnto_core/
definition.rs

1// Document-shape types for `.bnto.json` files -- the authoring view.
2//
3// These mirror the TypeScript `Definition` types in
4// `packages/@bnto/nodes/src/definition.ts`. They represent the full document
5// users author and the editor displays: position, metadata, ports, edges.
6//
7// The execution-pruned view (what the pipeline executor receives) lives in
8// `pipeline.rs` (`PipelineDefinition` / `PipelineNode`). That view strips
9// presentation fields and flattens `nodes` -> `children` for runtime walks.
10//
11// JSON conventions preserved from the TS source of truth:
12// - camelCase keys
13// - Optional fields omitted when `None` (never emitted as `null`)
14// - Empty port arrays emitted (not omitted) so the shape is regular
15// - `nodes` and `edges` omitted on leaves, present on containers
16
17use serde::{Deserialize, Serialize};
18use std::collections::BTreeMap;
19#[cfg(feature = "ts")]
20use ts_rs::TS;
21
22use crate::pipeline::PipelineSettings;
23
24// --- ts-rs export path ---
25//
26// The `ts` feature emits TypeScript interfaces into
27// `packages/@bnto/nodes/src/generated/definitionTypes/<StructName>.ts`.
28// Paths are relative to `bnto-core/`'s CARGO_MANIFEST_DIR.
29// Run: `cargo test -p bnto-core --features ts export_bindings_`
30
31/// Full document shape for a node in a `.bnto.json` file.
32///
33/// This is the authoring view -- richer than `PipelineNode` (which is the
34/// execution-pruned view). The `settings` field is only meaningful at the
35/// root of a recipe document; nested nodes typically omit it.
36#[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    /// Recipe-level dependencies — external tools needed at runtime.
73    /// Only meaningful at the root of a recipe document; nested nodes omit it.
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub requires: Vec<crate::Dependency>,
76    /// User-facing field declarations for recipe-level controls.
77    /// When present, the TUI/editor renders these instead of raw processor params.
78    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
79    pub fields: BTreeMap<String, crate::FieldDef>,
80}
81
82/// 2D coordinate for a node on the editor canvas.
83#[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/// Authoring metadata attached to every node (description, timestamps, tags).
98/// All fields are optional; an empty `Metadata` serializes to `{}`.
99#[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/// An input or output port on a node. `handle` is used when a node exposes
131/// multiple logical slots (e.g. an `if` node with `then` / `else` outputs).
132#[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/// A directed connection between two nodes in a container.
150/// `source_handle` / `target_handle` disambiguate when ports have handles.
151#[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
173/// Default for `parameters` when the key is missing entirely. Produces `{}`,
174/// matching the TS convention that parameters is always an object.
175pub(crate) fn default_parameters() -> serde_json::Value {
176    serde_json::Value::Object(serde_json::Map::new())
177}
178
179// =============================================================================
180// Tests
181// =============================================================================
182
183#[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    // --- Shape tests: verify JSON key casing and None omission ---
209
210    #[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    // --- Round-trip tests against real fixtures ---
333
334    #[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        // Explicit-mode recipes exercise containers, I/O nodes, nested loops.
359        // We verify the Rust representation is stable across serialize ->
360        // deserialize, not that the emitted JSON is byte-identical to the
361        // source. Fixtures store coordinates as JSON integers (e.g. `"x": 0`);
362        // Position is `f64`, so re-serialization emits `0.0`. That's an
363        // encoding artifact -- the Definition value itself is lossless.
364        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        // `parameters` is required in TS but this guards against recipes
404        // authored by older tooling that may omit it entirely.
405        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}