Skip to main content

bnto_core/
field_def.rs

1// Node-level field declarations — user-facing controls for nodes.
2//
3// Fields provide an interface layer between what users see and what
4// processors consume. A node declares typed fields (string, number,
5// boolean, enum) that render as form controls in the TUI/editor.
6// Field values are substituted into `{{fields.*}}` templates in the
7// node's parameters at execution time.
8
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11#[cfg(feature = "ts")]
12use ts_rs::TS;
13
14/// Ordered map of field name → definition.
15pub type FieldDefs = BTreeMap<String, FieldDef>;
16
17/// A single user-facing field — declared in recipe JSON, rendered by TUI/editor.
18#[cfg_attr(feature = "ts", derive(TS))]
19#[cfg_attr(
20    feature = "ts",
21    ts(
22        export,
23        export_to = "../../../../packages/@bnto/nodes/src/generated/definitionTypes/"
24    )
25)]
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
27#[serde(rename_all = "camelCase", tag = "type")]
28pub enum FieldDef {
29    #[serde(rename = "string")]
30    String {
31        label: String,
32        #[serde(default, skip_serializing_if = "Option::is_none")]
33        #[cfg_attr(feature = "ts", ts(optional))]
34        description: Option<String>,
35        #[serde(default, skip_serializing_if = "Option::is_none")]
36        #[cfg_attr(feature = "ts", ts(optional))]
37        default: Option<String>,
38        #[serde(default, skip_serializing_if = "Option::is_none")]
39        #[cfg_attr(feature = "ts", ts(optional))]
40        placeholder: Option<String>,
41        #[serde(default, skip_serializing_if = "Option::is_none")]
42        #[cfg_attr(feature = "ts", ts(optional))]
43        order: Option<u32>,
44    },
45    #[serde(rename = "number")]
46    Number {
47        label: String,
48        #[serde(default, skip_serializing_if = "Option::is_none")]
49        #[cfg_attr(feature = "ts", ts(optional))]
50        description: Option<String>,
51        #[serde(default, skip_serializing_if = "Option::is_none")]
52        #[cfg_attr(feature = "ts", ts(optional))]
53        default: Option<f64>,
54        #[serde(default, skip_serializing_if = "Option::is_none")]
55        #[cfg_attr(feature = "ts", ts(optional))]
56        min: Option<f64>,
57        #[serde(default, skip_serializing_if = "Option::is_none")]
58        #[cfg_attr(feature = "ts", ts(optional))]
59        max: Option<f64>,
60        #[serde(default, skip_serializing_if = "Option::is_none")]
61        #[cfg_attr(feature = "ts", ts(optional))]
62        step: Option<f64>,
63        #[serde(default, skip_serializing_if = "Option::is_none")]
64        #[cfg_attr(feature = "ts", ts(optional))]
65        suffix: Option<String>,
66        #[serde(default, skip_serializing_if = "Option::is_none")]
67        #[cfg_attr(feature = "ts", ts(optional))]
68        order: Option<u32>,
69    },
70    #[serde(rename = "boolean")]
71    Boolean {
72        label: String,
73        #[serde(default, skip_serializing_if = "Option::is_none")]
74        #[cfg_attr(feature = "ts", ts(optional))]
75        description: Option<String>,
76        #[serde(default, skip_serializing_if = "Option::is_none")]
77        #[cfg_attr(feature = "ts", ts(optional))]
78        default: Option<bool>,
79        #[serde(default, skip_serializing_if = "Option::is_none")]
80        #[cfg_attr(feature = "ts", ts(optional))]
81        order: Option<u32>,
82    },
83    #[serde(rename = "enum")]
84    Enum {
85        label: String,
86        options: Vec<FieldOption>,
87        #[serde(default, skip_serializing_if = "Option::is_none")]
88        #[cfg_attr(feature = "ts", ts(optional))]
89        description: Option<String>,
90        #[serde(default, skip_serializing_if = "Option::is_none")]
91        #[cfg_attr(feature = "ts", ts(optional))]
92        default: Option<String>,
93        #[serde(default, skip_serializing_if = "Option::is_none")]
94        #[cfg_attr(feature = "ts", ts(optional))]
95        order: Option<u32>,
96    },
97}
98
99/// A single option in an enum field.
100#[cfg_attr(feature = "ts", derive(TS))]
101#[cfg_attr(
102    feature = "ts",
103    ts(
104        export,
105        export_to = "../../../../packages/@bnto/nodes/src/generated/definitionTypes/"
106    )
107)]
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
109pub struct FieldOption {
110    pub value: String,
111    pub label: String,
112}
113
114impl FieldDef {
115    /// The display order for this field (lower = first).
116    pub fn order(&self) -> u32 {
117        match self {
118            Self::String { order, .. }
119            | Self::Number { order, .. }
120            | Self::Boolean { order, .. }
121            | Self::Enum { order, .. } => order.unwrap_or(u32::MAX),
122        }
123    }
124
125    /// The default value as a JSON Value, or Null if none.
126    pub fn default_value(&self) -> serde_json::Value {
127        match self {
128            Self::String { default, .. } => default
129                .as_ref()
130                .map(|s| serde_json::Value::String(s.clone()))
131                .unwrap_or(serde_json::Value::Null),
132            Self::Number { default, .. } => default
133                .and_then(serde_json::Number::from_f64)
134                .map(serde_json::Value::Number)
135                .unwrap_or(serde_json::Value::Null),
136            Self::Boolean { default, .. } => default
137                .map(serde_json::Value::Bool)
138                .unwrap_or(serde_json::Value::Null),
139            Self::Enum { default, .. } => default
140                .as_ref()
141                .map(|s| serde_json::Value::String(s.clone()))
142                .unwrap_or(serde_json::Value::Null),
143        }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn string_field_round_trips() {
153        let json = r#"{"type":"string","label":"Name","default":"hello","placeholder":"Enter..."}"#;
154        let field: FieldDef = serde_json::from_str(json).unwrap();
155        let FieldDef::String {
156            label,
157            default,
158            placeholder,
159            ..
160        } = &field
161        else {
162            panic!("expected String variant");
163        };
164        assert_eq!(label, "Name");
165        assert_eq!(default.as_deref(), Some("hello"));
166        assert_eq!(placeholder.as_deref(), Some("Enter..."));
167        let serialized = serde_json::to_string(&field).unwrap();
168        let round_tripped: FieldDef = serde_json::from_str(&serialized).unwrap();
169        assert_eq!(field, round_tripped);
170    }
171
172    #[test]
173    fn number_field_round_trips() {
174        let json = r#"{"type":"number","label":"Quality","default":80,"min":1,"max":100,"step":1,"suffix":"%"}"#;
175        let field: FieldDef = serde_json::from_str(json).unwrap();
176        let FieldDef::Number {
177            label,
178            default,
179            min,
180            max,
181            step,
182            suffix,
183            ..
184        } = &field
185        else {
186            panic!("expected Number variant");
187        };
188        assert_eq!(label, "Quality");
189        assert_eq!(*default, Some(80.0));
190        assert_eq!(*min, Some(1.0));
191        assert_eq!(*max, Some(100.0));
192        assert_eq!(*step, Some(1.0));
193        assert_eq!(suffix.as_deref(), Some("%"));
194        let serialized = serde_json::to_string(&field).unwrap();
195        let round_tripped: FieldDef = serde_json::from_str(&serialized).unwrap();
196        assert_eq!(field, round_tripped);
197    }
198
199    #[test]
200    fn boolean_field_round_trips() {
201        let json = r#"{"type":"boolean","label":"Strip Metadata","default":true}"#;
202        let field: FieldDef = serde_json::from_str(json).unwrap();
203        let FieldDef::Boolean { label, default, .. } = &field else {
204            panic!("expected Boolean variant");
205        };
206        assert_eq!(label, "Strip Metadata");
207        assert_eq!(*default, Some(true));
208        let serialized = serde_json::to_string(&field).unwrap();
209        let round_tripped: FieldDef = serde_json::from_str(&serialized).unwrap();
210        assert_eq!(field, round_tripped);
211    }
212
213    #[test]
214    fn enum_field_round_trips() {
215        let json = r#"{
216            "type": "enum",
217            "label": "Format",
218            "options": [
219                {"value": "mp4", "label": "MP4"},
220                {"value": "webm", "label": "WebM"}
221            ],
222            "default": "mp4"
223        }"#;
224        let field: FieldDef = serde_json::from_str(json).unwrap();
225        let FieldDef::Enum {
226            label,
227            options,
228            default,
229            ..
230        } = &field
231        else {
232            panic!("expected Enum variant");
233        };
234        assert_eq!(label, "Format");
235        assert_eq!(options.len(), 2);
236        assert_eq!(options[0].value, "mp4");
237        assert_eq!(options[0].label, "MP4");
238        assert_eq!(default.as_deref(), Some("mp4"));
239        let serialized = serde_json::to_string(&field).unwrap();
240        let round_tripped: FieldDef = serde_json::from_str(&serialized).unwrap();
241        assert_eq!(field, round_tripped);
242    }
243
244    #[test]
245    fn tagged_union_discriminator() {
246        let json = r#"{"type":"enum","label":"X","options":[]}"#;
247        let field: FieldDef = serde_json::from_str(json).unwrap();
248        assert!(matches!(field, FieldDef::Enum { .. }));
249
250        let json = r#"{"type":"string","label":"X"}"#;
251        let field: FieldDef = serde_json::from_str(json).unwrap();
252        assert!(matches!(field, FieldDef::String { .. }));
253    }
254
255    #[test]
256    fn field_defs_map_round_trips() {
257        let json = r#"{
258            "format": {"type":"enum","label":"Format","options":[{"value":"mp4","label":"MP4"}],"default":"mp4","order":1},
259            "quality": {"type":"number","label":"Quality","default":80,"min":1,"max":100,"order":2}
260        }"#;
261        let fields: FieldDefs = serde_json::from_str(json).unwrap();
262        assert_eq!(fields.len(), 2);
263        assert!(matches!(fields["format"], FieldDef::Enum { .. }));
264        assert!(matches!(fields["quality"], FieldDef::Number { .. }));
265    }
266
267    #[test]
268    fn order_defaults_to_max() {
269        let field = FieldDef::String {
270            label: "X".into(),
271            description: None,
272            default: None,
273            placeholder: None,
274            order: None,
275        };
276        assert_eq!(field.order(), u32::MAX);
277    }
278
279    #[test]
280    fn order_returns_explicit_value() {
281        let field = FieldDef::Enum {
282            label: "X".into(),
283            options: vec![],
284            description: None,
285            default: None,
286            order: Some(3),
287        };
288        assert_eq!(field.order(), 3);
289    }
290
291    #[test]
292    fn default_value_for_each_variant() {
293        let s = FieldDef::String {
294            label: "X".into(),
295            description: None,
296            default: Some("hello".into()),
297            placeholder: None,
298            order: None,
299        };
300        assert_eq!(s.default_value(), serde_json::json!("hello"));
301
302        let n = FieldDef::Number {
303            label: "X".into(),
304            description: None,
305            default: Some(42.0),
306            min: None,
307            max: None,
308            step: None,
309            suffix: None,
310            order: None,
311        };
312        assert_eq!(n.default_value(), serde_json::json!(42.0));
313
314        let b = FieldDef::Boolean {
315            label: "X".into(),
316            description: None,
317            default: Some(false),
318            order: None,
319        };
320        assert_eq!(b.default_value(), serde_json::json!(false));
321
322        let no_default = FieldDef::String {
323            label: "X".into(),
324            description: None,
325            default: None,
326            placeholder: None,
327            order: None,
328        };
329        assert!(no_default.default_value().is_null());
330    }
331
332    #[test]
333    fn optional_fields_omitted_on_serialization() {
334        let field = FieldDef::String {
335            label: "Name".into(),
336            description: None,
337            default: None,
338            placeholder: None,
339            order: None,
340        };
341        let json = serde_json::to_string(&field).unwrap();
342        assert!(!json.contains("description"));
343        assert!(!json.contains("default"));
344        assert!(!json.contains("placeholder"));
345        assert!(!json.contains("order"));
346    }
347}