Skip to main content

bnto_core/
metadata.rs

1// Node Metadata — Self-describing processor definitions.
2//
3// Each processor implements `metadata()` on the `NodeProcessor` trait. The
4// registry collects all metadata into a catalog exported via `node_catalog()`
5// WASM function, making the engine the single source of truth for node defs.
6
7use serde::Serialize;
8
9// --- ParamCondition — Conditional Visibility / Requirement Rules ---
10//
11// Declares when a parameter should be shown/required based on other param values.
12// `Single` = one condition, `Any` = OR logic across multiple conditions.
13
14/// A single condition entry: "when `param` has the value `equals`".
15///
16/// Used both standalone (in `ParamCondition::Single`) and as entries
17/// in the `ParamCondition::Any` array.
18#[derive(Debug, Clone, Serialize, PartialEq)]
19#[serde(rename_all = "camelCase")]
20pub struct ParamConditionEntry {
21    /// The name of the parameter to check against.
22    /// Example: `"operation"` — check the value of the "operation" parameter.
23    pub param: String,
24
25    /// The value that triggers visibility/requirement.
26    /// Example: `"resize"` — only show this param when operation is "resize".
27    pub equals: String,
28}
29
30/// Conditional visibility/requirement rule for a parameter.
31///
32/// Tells the UI when to show a parameter or when to make it required.
33/// Uses `#[serde(untagged)]` so Single serializes as a plain object and
34/// Any serializes as an array — no type discriminator field needed.
35#[derive(Debug, Clone, Serialize, PartialEq)]
36#[serde(untagged)]
37pub enum ParamCondition {
38    /// Show/require when a single parameter matches a value.
39    /// Serializes as: `{"param": "operation", "equals": "resize"}`
40    Single(ParamConditionEntry),
41
42    /// Show/require when ANY of multiple conditions match (OR logic).
43    /// Serializes as: `[{"param": "...", "equals": "..."}, ...]`
44    Any(Vec<ParamConditionEntry>),
45}
46
47// --- InputCardinality ---
48
49/// Declares how a processor expects to receive files for smart iteration.
50/// Used by the auto-iteration executor to partition flat node sequences
51/// into implicit per-file loops.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
53#[serde(rename_all = "camelCase")]
54pub enum InputCardinality {
55    /// Processes one file at a time. Contiguous perFile nodes get wrapped
56    /// in an implicit per-file loop in auto mode.
57    #[default]
58    PerFile,
59    /// Needs the full batch of files at once (e.g., zip, concat, merge).
60    /// Acts as an iteration barrier in auto mode.
61    Batch,
62}
63
64// --- NodeCategory ---
65
66/// The broad category a node belongs to. Used for UI grouping and filtering.
67/// Serialized as kebab-case to match `@bnto/nodes` convention.
68#[derive(Debug, Clone, Serialize, PartialEq)]
69#[serde(rename_all = "kebab-case")]
70pub enum NodeCategory {
71    /// Image processing — compress, resize, convert formats
72    Image,
73    /// Spreadsheet/CSV operations — clean, rename columns
74    Spreadsheet,
75    /// File system operations — rename files
76    File,
77    /// Data transformation (future) — JSON, XML, text
78    Data,
79    /// Network operations (future) — HTTP requests, API calls
80    Network,
81    /// Control flow (future) — loops, conditionals, groups
82    Control,
83    /// System operations (future) — shell commands, environment
84    System,
85    /// Input/output nodes — file input, file output
86    Io,
87}
88
89// --- ParameterType ---
90
91/// The type of a node parameter. Determines what UI control to render.
92/// Tagged with `"type"` in JSON (e.g., `{"type": "number"}`).
93#[derive(Debug, Clone, Serialize, PartialEq, Default)]
94#[serde(tag = "type", rename_all = "camelCase")]
95pub enum ParameterType {
96    /// A numeric value (integer or float). Used for quality, width, height.
97    Number,
98    /// A text string. Used for find/replace patterns, prefixes, suffixes.
99    #[default]
100    String,
101    /// A true/false toggle. Used for trimWhitespace, removeEmptyRows.
102    Boolean,
103    /// A choice from a fixed set of options (like a dropdown/select).
104    /// The `options` field lists all valid values.
105    Enum {
106        /// The list of valid values for this enum parameter.
107        /// Example: `["jpeg", "png", "webp"]` for image format selection.
108        options: Vec<std::string::String>,
109    },
110    /// A structured object (key-value map). Used for column rename mappings.
111    Object,
112    /// A file upload parameter (base64-encoded). Used for overlay images, etc.
113    /// The `accept` field lists allowed MIME types for the file picker.
114    File {
115        /// Accepted MIME types (e.g., `["image/png", "image/jpeg"]`).
116        accept: Vec<std::string::String>,
117    },
118}
119
120// --- Constraints ---
121
122/// Optional constraints on a parameter's value (min/max range, required flag).
123/// Used for validation and UI hints (slider bounds, required markers).
124#[derive(Debug, Clone, Serialize, PartialEq)]
125#[serde(rename_all = "camelCase")]
126pub struct Constraints {
127    /// Minimum allowed value (for numeric parameters).
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub min: Option<f64>,
130
131    /// Maximum allowed value (for numeric parameters).
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub max: Option<f64>,
134
135    /// Whether this parameter must be provided.
136    pub required: bool,
137}
138
139// --- ParameterDef ---
140
141/// A complete definition of one parameter a node accepts. Provides
142/// everything the engine (validation) and UI (control rendering) need.
143#[derive(Debug, Clone, Serialize, PartialEq)]
144#[serde(rename_all = "camelCase")]
145pub struct ParameterDef {
146    /// The parameter's key name in config JSON (e.g., `"quality"`).
147    pub name: std::string::String,
148
149    /// Human-readable label for the UI.
150    pub label: std::string::String,
151
152    /// Description shown as tooltip or help text.
153    pub description: std::string::String,
154
155    /// Value type — determines what UI control to render.
156    pub param_type: ParameterType,
157
158    /// Default value (heterogeneous type via `serde_json::Value`).
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub default: Option<serde_json::Value>,
161
162    /// Optional validation constraints (min/max range, required flag).
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub constraints: Option<Constraints>,
165
166    // --- UI Metadata Fields ---
167    /// Placeholder text for input controls.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub placeholder: Option<String>,
170
171    /// Show this parameter only when another parameter matches a value.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub visible_when: Option<ParamCondition>,
174
175    /// Require this parameter only when another parameter matches a value.
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub required_when: Option<ParamCondition>,
178
179    /// Whether this param can be surfaced in container config panels.
180    /// Defaults to `true`. Set `false` for internal wiring params.
181    #[serde(default = "default_true")]
182    pub surfaceable: bool,
183}
184
185/// Serde default for `surfaceable` field during deserialization.
186#[allow(dead_code)]
187fn default_true() -> bool {
188    true
189}
190
191/// Manual Default because `surfaceable` must default to `true`, not `false`.
192impl Default for ParameterDef {
193    fn default() -> Self {
194        Self {
195            name: String::default(),
196            label: String::default(),
197            description: String::default(),
198            param_type: ParameterType::default(),
199            default: None,
200            constraints: None,
201            placeholder: None,
202            visible_when: None,
203            required_when: None,
204            surfaceable: true,
205        }
206    }
207}
208
209// --- NodeTypeInfo — Node-type-level metadata (all 15 types) ---
210//
211// Separate from NodeMetadata because NodeMetadata describes a PROCESSOR
212// (e.g., "image-compress") while NodeTypeInfo describes a NODE TYPE
213// (e.g., "image-compress") — one per node type.
214// Includes types the engine doesn't have processors for yet (http-request,
215// shell-command). Codegen generates TS `NODE_TYPE_INFO` from this.
216
217/// Everything the UI needs to know about a node type, independent of any
218/// specific processor/operation. The engine's authoritative type registry.
219#[derive(Debug, Clone, Serialize, PartialEq)]
220#[serde(rename_all = "camelCase")]
221pub struct NodeTypeInfo {
222    /// Type name as used in `.bnto.json` (e.g., `"image-compress"`, `"file-rename"`).
223    pub name: String,
224    /// Human-readable display label.
225    pub label: String,
226    /// One-sentence description.
227    pub description: String,
228    /// Category for UI grouping/filtering.
229    pub category: NodeCategory,
230    /// Whether this node can contain child nodes.
231    pub is_container: bool,
232    /// Platforms this type runs on (e.g., `["browser"]`, `["server"]`).
233    pub platforms: Vec<String>,
234    /// Lucide icon name — consumers resolve to their own icon component.
235    pub icon: String,
236}
237
238/// Constructs a `NodeTypeInfo` from positional fields. Reduces per-entry
239/// boilerplate in `all_node_types()` — keeps the table scannable.
240macro_rules! node_type {
241    ($name:expr, $label:expr, $desc:expr, $cat:expr, $container:expr, $platform:expr, $icon:expr) => {
242        NodeTypeInfo {
243            name: $name.to_string(),
244            label: $label.to_string(),
245            description: $desc.to_string(),
246            category: $cat,
247            is_container: $container,
248            platforms: vec![$platform.to_string()],
249            icon: $icon.to_string(),
250        }
251    };
252}
253
254/// Return metadata for all 19 registered node types.
255///
256/// Single source of truth for the engine's node type registry.
257/// Composed from per-category helpers, then sorted alphabetically for stable output.
258pub fn all_node_types() -> Vec<NodeTypeInfo> {
259    let mut types = Vec::with_capacity(19);
260    types.extend(control_node_types());
261    types.extend(data_node_types());
262    types.extend(file_node_types());
263    types.extend(image_node_types());
264    types.extend(io_node_types());
265    types.extend(network_node_types());
266    types.extend(spreadsheet_node_types());
267    types.extend(system_node_types());
268    types.sort_by(|a, b| a.name.cmp(&b.name));
269    types
270}
271
272fn control_node_types() -> Vec<NodeTypeInfo> {
273    vec![
274        node_type!(
275            "group",
276            "Group",
277            "Container for child nodes. Orchestrates sequential or parallel execution.",
278            NodeCategory::Control,
279            true,
280            "browser",
281            "box"
282        ),
283        node_type!(
284            "loop",
285            "Loop",
286            "Iterate over arrays (forEach), repeat N times, or loop while condition.",
287            NodeCategory::Control,
288            true,
289            "browser",
290            "repeat"
291        ),
292        node_type!(
293            "parallel",
294            "Parallel",
295            "Execute tasks concurrently with configurable worker pool and error strategy.",
296            NodeCategory::Control,
297            true,
298            "browser",
299            "git-fork"
300        ),
301    ]
302}
303
304fn data_node_types() -> Vec<NodeTypeInfo> {
305    vec![
306        node_type!(
307            "edit-fields",
308            "Edit Fields",
309            "Set field values from static values or template expressions.",
310            NodeCategory::Data,
311            false,
312            "browser",
313            "pen-line"
314        ),
315        node_type!(
316            "transform",
317            "Transform",
318            "Transform data using expressions (single value) or field mappings.",
319            NodeCategory::Data,
320            false,
321            "browser",
322            "arrow-left-right"
323        ),
324    ]
325}
326
327fn file_node_types() -> Vec<NodeTypeInfo> {
328    vec![node_type!(
329        "file-rename",
330        "Rename Files",
331        "Transform filenames using patterns, find/replace, and case rules.",
332        NodeCategory::File,
333        false,
334        "browser",
335        "folder-open"
336    )]
337}
338
339fn image_node_types() -> Vec<NodeTypeInfo> {
340    vec![
341        node_type!(
342            "image-compress",
343            "Compress Images",
344            "Reduce image file size while maintaining quality.",
345            NodeCategory::Image,
346            false,
347            "browser",
348            "image"
349        ),
350        node_type!(
351            "image-convert",
352            "Convert Image Format",
353            "Convert images between JPEG, PNG, and WebP formats.",
354            NodeCategory::Image,
355            false,
356            "browser",
357            "image"
358        ),
359        node_type!(
360            "image-resize",
361            "Resize Images",
362            "Change image dimensions while maintaining quality.",
363            NodeCategory::Image,
364            false,
365            "browser",
366            "image"
367        ),
368        node_type!(
369            "image-strip-exif",
370            "Strip EXIF",
371            "Remove all EXIF metadata from images (GPS, camera info, timestamps).",
372            NodeCategory::Image,
373            false,
374            "browser",
375            "image"
376        ),
377        node_type!(
378            "image-overlay",
379            "Overlay Image",
380            "Overlay an image onto source images at a configurable position, size, and opacity.",
381            NodeCategory::Image,
382            false,
383            "browser",
384            "stamp"
385        ),
386    ]
387}
388
389fn io_node_types() -> Vec<NodeTypeInfo> {
390    vec![
391        node_type!(
392            "input",
393            "Input",
394            "Declares how data enters the recipe.",
395            NodeCategory::Io,
396            false,
397            "browser",
398            "file-up"
399        ),
400        node_type!(
401            "output",
402            "Output",
403            "Declares how results are delivered.",
404            NodeCategory::Io,
405            false,
406            "browser",
407            "download"
408        ),
409    ]
410}
411
412fn network_node_types() -> Vec<NodeTypeInfo> {
413    vec![node_type!(
414        "http-request",
415        "HTTP Request",
416        "Make HTTP requests to APIs (GET, POST, PUT, DELETE, etc.).",
417        NodeCategory::Network,
418        false,
419        "server",
420        "globe"
421    )]
422}
423
424fn spreadsheet_node_types() -> Vec<NodeTypeInfo> {
425    vec![
426        node_type!(
427            "spreadsheet-clean",
428            "Clean CSV",
429            "Remove empty rows, trim whitespace, and deduplicate CSV data.",
430            NodeCategory::Spreadsheet,
431            false,
432            "browser",
433            "sheet"
434        ),
435        node_type!(
436            "spreadsheet-convert",
437            "CSV to JSON",
438            "Convert CSV data to JSON format with configurable delimiters.",
439            NodeCategory::Spreadsheet,
440            false,
441            "browser",
442            "sheet"
443        ),
444        node_type!(
445            "spreadsheet-merge",
446            "Merge CSV",
447            "Combine multiple CSV files into one with header reconciliation and deduplication.",
448            NodeCategory::Spreadsheet,
449            false,
450            "browser",
451            "sheet"
452        ),
453        node_type!(
454            "spreadsheet-rename",
455            "Rename CSV Columns",
456            "Rename column headers in a CSV file.",
457            NodeCategory::Spreadsheet,
458            false,
459            "browser",
460            "sheet"
461        ),
462    ]
463}
464
465fn system_node_types() -> Vec<NodeTypeInfo> {
466    vec![node_type!(
467        "shell-command",
468        "Shell Command",
469        "Execute shell commands with stall detection, retry, and streaming output.",
470        NodeCategory::System,
471        false,
472        "server",
473        "terminal"
474    )]
475}
476
477// --- NodeMetadata ---
478
479/// Complete self-description of a processor. Return type of
480/// `NodeProcessor::metadata()`. The `node_type` is the direct dispatch
481/// key (e.g., `"image-compress"`).
482#[derive(Debug, Clone, Serialize, PartialEq)]
483#[serde(rename_all = "camelCase")]
484pub struct NodeMetadata {
485    /// Per-operation node type (e.g., `"image-compress"`, `"spreadsheet-clean"`).
486    pub node_type: std::string::String,
487    /// Human-readable processor name.
488    pub name: std::string::String,
489    /// Description of what this processor does.
490    pub description: std::string::String,
491    /// Category for UI grouping/filtering.
492    pub category: NodeCategory,
493    /// Accepted MIME types. Empty means "any file type".
494    pub accepts: Vec<std::string::String>,
495    /// Platforms this processor runs on (`"browser"`, `"server"`, `"desktop"`).
496    pub platforms: Vec<std::string::String>,
497    /// Parameters with types, defaults, and constraints.
498    pub parameters: Vec<ParameterDef>,
499    /// How this processor expects to receive files: one at a time or as a batch.
500    /// Defaults to `PerFile`. Used by the auto-iteration executor.
501    #[serde(default)]
502    pub input_cardinality: InputCardinality,
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    // --- InputCardinality Tests ---
510
511    #[test]
512    fn test_input_cardinality_defaults_to_per_file() {
513        let cardinality = InputCardinality::default();
514        assert_eq!(cardinality, InputCardinality::PerFile);
515    }
516
517    #[test]
518    fn test_input_cardinality_serializes_camel_case() {
519        let per_file = serde_json::to_string(&InputCardinality::PerFile).unwrap();
520        assert_eq!(per_file, r#""perFile""#);
521
522        let batch = serde_json::to_string(&InputCardinality::Batch).unwrap();
523        assert_eq!(batch, r#""batch""#);
524    }
525
526    #[test]
527    fn test_metadata_with_input_cardinality_round_trip() {
528        let metadata = NodeMetadata {
529            node_type: "image-compress".to_string(),
530            name: "Compress Images".to_string(),
531            description: "Reduce image file size".to_string(),
532            category: NodeCategory::Image,
533            accepts: vec!["image/jpeg".to_string()],
534            platforms: vec!["browser".to_string()],
535            parameters: vec![],
536            input_cardinality: InputCardinality::PerFile,
537        };
538        let json = serde_json::to_string(&metadata).unwrap();
539        assert!(json.contains(r#""inputCardinality":"perFile""#));
540
541        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
542        assert_eq!(parsed["inputCardinality"], "perFile");
543    }
544
545    #[test]
546    fn test_metadata_with_batch_cardinality() {
547        let metadata = NodeMetadata {
548            node_type: "zip-files".to_string(),
549            name: "Zip Files".to_string(),
550            description: "Bundle files into a zip archive".to_string(),
551            category: NodeCategory::File,
552            accepts: vec![],
553            platforms: vec!["browser".to_string()],
554            parameters: vec![],
555            input_cardinality: InputCardinality::Batch,
556        };
557        let json = serde_json::to_string(&metadata).unwrap();
558        assert!(json.contains(r#""inputCardinality":"batch""#));
559    }
560
561    // --- NodeTypeInfo Tests ---
562
563    #[test]
564    fn test_all_node_types_returns_19_entries() {
565        // The engine defines all 19 node types.
566        let types = all_node_types();
567        assert_eq!(types.len(), 19, "Should have exactly 19 node types");
568    }
569
570    #[test]
571    fn test_all_node_types_sorted_alphabetically() {
572        // Entries should be sorted by name for deterministic output.
573        let types = all_node_types();
574        let names: Vec<&str> = types.iter().map(|t| t.name.as_str()).collect();
575        let mut sorted = names.clone();
576        sorted.sort();
577        assert_eq!(names, sorted, "Node types should be alphabetically sorted");
578    }
579
580    #[test]
581    fn test_all_node_types_unique_names() {
582        // Every node type name should be unique.
583        let types = all_node_types();
584        let mut names: Vec<&str> = types.iter().map(|t| t.name.as_str()).collect();
585        names.sort();
586        names.dedup();
587        assert_eq!(names.len(), 19, "All node type names should be unique");
588    }
589
590    #[test]
591    fn test_container_types_are_group_loop_parallel() {
592        // Only group, loop, and parallel should be containers.
593        let types = all_node_types();
594        let mut containers: Vec<&str> = types
595            .iter()
596            .filter(|t| t.is_container)
597            .map(|t| t.name.as_str())
598            .collect();
599        containers.sort();
600        assert_eq!(containers, vec!["group", "loop", "parallel"]);
601    }
602
603    #[test]
604    fn test_io_types_are_input_output() {
605        // Only input and output should have the Io category.
606        let types = all_node_types();
607        let mut io_types: Vec<&str> = types
608            .iter()
609            .filter(|t| t.category == NodeCategory::Io)
610            .map(|t| t.name.as_str())
611            .collect();
612        io_types.sort();
613        assert_eq!(io_types, vec!["input", "output"]);
614    }
615
616    #[test]
617    fn test_server_only_types() {
618        // http-request and shell-command should only have "server" platform.
619        let types = all_node_types();
620        let mut server_only: Vec<&str> = types
621            .iter()
622            .filter(|t| !t.platforms.contains(&"browser".to_string()))
623            .map(|t| t.name.as_str())
624            .collect();
625        server_only.sort();
626        assert_eq!(server_only, vec!["http-request", "shell-command"]);
627    }
628
629    #[test]
630    fn test_node_type_info_serializes_camel_case() {
631        // NodeTypeInfo should serialize with camelCase keys.
632        let info = NodeTypeInfo {
633            name: "image".to_string(),
634            label: "Image".to_string(),
635            description: "Image processing".to_string(),
636            category: NodeCategory::Image,
637            is_container: false,
638            platforms: vec!["browser".to_string()],
639            icon: "image".to_string(),
640        };
641        let json = serde_json::to_string(&info).unwrap();
642        // isContainer should be camelCase in JSON
643        assert!(json.contains(r#""isContainer":false"#));
644        assert!(!json.contains("is_container"));
645    }
646
647    // --- Serialization Tests ---
648    // These verify that our types serialize to the expected JSON format,
649    // with camelCase keys, skip_serializing_if working, etc.
650
651    #[test]
652    fn test_category_serializes_to_kebab_case() {
653        // NodeCategory variants should serialize as kebab-case strings.
654        let json = serde_json::to_string(&NodeCategory::Image).unwrap();
655        assert_eq!(json, r#""image""#);
656
657        let json = serde_json::to_string(&NodeCategory::Spreadsheet).unwrap();
658        assert_eq!(json, r#""spreadsheet""#);
659
660        let json = serde_json::to_string(&NodeCategory::File).unwrap();
661        assert_eq!(json, r#""file""#);
662
663        let json = serde_json::to_string(&NodeCategory::Io).unwrap();
664        assert_eq!(json, r#""io""#);
665    }
666
667    #[test]
668    fn test_parameter_type_number_serialization() {
669        // Number type serializes with a "type" tag.
670        let json = serde_json::to_string(&ParameterType::Number).unwrap();
671        assert_eq!(json, r#"{"type":"number"}"#);
672    }
673
674    #[test]
675    fn test_parameter_type_enum_serialization() {
676        // Enum type includes the options list.
677        let param = ParameterType::Enum {
678            options: vec!["jpeg".to_string(), "png".to_string(), "webp".to_string()],
679        };
680        let json = serde_json::to_string(&param).unwrap();
681        assert!(json.contains(r#""type":"enum""#));
682        assert!(json.contains(r#""options":["jpeg","png","webp"]"#));
683    }
684
685    #[test]
686    fn test_constraints_skips_none_fields() {
687        // Fields that are None should be omitted from the JSON output.
688        let constraints = Constraints {
689            min: Some(1.0),
690            max: None,
691            required: false,
692        };
693        let json = serde_json::to_string(&constraints).unwrap();
694        // Should have "min" but NOT "max".
695        assert!(json.contains(r#""min":1.0"#));
696        assert!(!json.contains("max"));
697        assert!(json.contains(r#""required":false"#));
698    }
699
700    #[test]
701    fn test_constraints_includes_all_fields_when_present() {
702        let constraints = Constraints {
703            min: Some(1.0),
704            max: Some(100.0),
705            required: true,
706        };
707        let json = serde_json::to_string(&constraints).unwrap();
708        assert!(json.contains(r#""min":1.0"#));
709        assert!(json.contains(r#""max":100.0"#));
710        assert!(json.contains(r#""required":true"#));
711    }
712
713    #[test]
714    fn test_parameter_def_serializes_camel_case() {
715        // ParameterDef fields should be camelCase in JSON.
716        let param = ParameterDef {
717            name: "quality".to_string(),
718            label: "Quality".to_string(),
719            description: "Compression quality".to_string(),
720            param_type: ParameterType::Number,
721            default: Some(serde_json::json!(80)),
722            constraints: Some(Constraints {
723                min: Some(1.0),
724                max: Some(100.0),
725                required: false,
726            }),
727            ..Default::default()
728        };
729        let json = serde_json::to_string(&param).unwrap();
730        // Should use "paramType" not "param_type".
731        assert!(json.contains(r#""paramType""#));
732        assert!(!json.contains("param_type"));
733    }
734
735    #[test]
736    fn test_parameter_def_skips_none_default() {
737        // When default is None, it should be omitted from JSON.
738        let param = ParameterDef {
739            name: "width".to_string(),
740            label: "Width".to_string(),
741            description: "Target width".to_string(),
742            param_type: ParameterType::Number,
743            ..Default::default()
744        };
745        let json = serde_json::to_string(&param).unwrap();
746        assert!(!json.contains("default"));
747        assert!(!json.contains("constraints"));
748        // UI metadata fields should also be omitted when None.
749        assert!(!json.contains("placeholder"));
750        assert!(!json.contains("visibleWhen"));
751        assert!(!json.contains("requiredWhen"));
752    }
753
754    #[test]
755    fn test_parameter_def_surfaceable_defaults_to_true() {
756        // The `surfaceable` field should default to `true` — most params are
757        // user-facing controls that should appear in surfaced container views.
758        let param = ParameterDef {
759            name: "quality".to_string(),
760            label: "Quality".to_string(),
761            description: "Compression quality".to_string(),
762            param_type: ParameterType::Number,
763            ..Default::default()
764        };
765        // Default::default() should give surfaceable = true.
766        assert!(param.surfaceable, "surfaceable should default to true");
767        // And it should serialize with the field present.
768        let json = serde_json::to_string(&param).unwrap();
769        assert!(json.contains(r#""surfaceable":true"#));
770    }
771
772    #[test]
773    fn test_parameter_def_surfaceable_false_serializes() {
774        // Internal wiring params (like loop `items`) should be explicitly
775        // marked `surfaceable: false` so the editor doesn't surface them.
776        let param = ParameterDef {
777            name: "items".to_string(),
778            label: "Items".to_string(),
779            description: "Template expression for iteration items".to_string(),
780            param_type: ParameterType::String,
781            surfaceable: false,
782            ..Default::default()
783        };
784        assert!(!param.surfaceable);
785        let json = serde_json::to_string(&param).unwrap();
786        assert!(json.contains(r#""surfaceable":false"#));
787    }
788
789    #[test]
790    fn test_node_metadata_serializes_camel_case() {
791        // NodeMetadata fields should be camelCase in JSON.
792        let metadata = NodeMetadata {
793            node_type: "image-compress".to_string(),
794            name: "Compress Images".to_string(),
795            description: "Reduce image file size".to_string(),
796            category: NodeCategory::Image,
797            accepts: vec![
798                "image/jpeg".to_string(),
799                "image/png".to_string(),
800                "image/webp".to_string(),
801            ],
802            platforms: vec!["browser".to_string()],
803            parameters: vec![],
804            input_cardinality: InputCardinality::PerFile,
805        };
806        let json = serde_json::to_string(&metadata).unwrap();
807        // Should use camelCase field names.
808        assert!(json.contains(r#""nodeType":"image-compress""#));
809        assert!(json.contains(r#""platforms":["browser"]"#));
810        assert!(!json.contains("node_type"));
811    }
812
813    #[test]
814    fn test_full_metadata_round_trip() {
815        // Build a complete NodeMetadata and verify it serializes to valid JSON
816        // that can be parsed back.
817        let metadata = NodeMetadata {
818            node_type: "image-compress".to_string(),
819            name: "Compress Images".to_string(),
820            description: "Reduce image file size while maintaining quality".to_string(),
821            category: NodeCategory::Image,
822            accepts: vec![
823                "image/jpeg".to_string(),
824                "image/png".to_string(),
825                "image/webp".to_string(),
826            ],
827            platforms: vec!["browser".to_string()],
828            parameters: vec![ParameterDef {
829                name: "quality".to_string(),
830                label: "Quality".to_string(),
831                description: "Compression quality (1-100)".to_string(),
832                param_type: ParameterType::Number,
833                default: Some(serde_json::json!(80)),
834                constraints: Some(Constraints {
835                    min: Some(1.0),
836                    max: Some(100.0),
837                    required: false,
838                }),
839                ..Default::default()
840            }],
841            input_cardinality: InputCardinality::PerFile,
842        };
843
844        // Serialize to JSON string.
845        let json = serde_json::to_string_pretty(&metadata).unwrap();
846
847        // Parse back to a generic JSON Value (round-trip test).
848        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
849
850        // Verify key fields are present and correct.
851        assert_eq!(parsed["nodeType"], "image-compress");
852        assert_eq!(parsed["category"], "image");
853        assert_eq!(parsed["platforms"][0], "browser");
854        assert_eq!(parsed["accepts"].as_array().unwrap().len(), 3);
855        assert_eq!(parsed["parameters"].as_array().unwrap().len(), 1);
856        assert_eq!(parsed["parameters"][0]["name"], "quality");
857        assert_eq!(parsed["parameters"][0]["default"], 80);
858    }
859
860    // --- ParamCondition Serialization Tests ---
861
862    #[test]
863    fn test_param_condition_single_serializes_as_object() {
864        // A Single condition should serialize as a flat JSON object
865        // with "param" and "equals" keys (camelCase).
866        let condition = ParamCondition::Single(ParamConditionEntry {
867            param: "operation".to_string(),
868            equals: "resize".to_string(),
869        });
870        let json = serde_json::to_string(&condition).unwrap();
871        // Should be a flat object, not wrapped in a type tag.
872        assert_eq!(json, r#"{"param":"operation","equals":"resize"}"#);
873    }
874
875    #[test]
876    fn test_param_condition_any_serializes_as_array() {
877        // An Any condition should serialize as a JSON array of condition objects.
878        // This represents OR logic: show when ANY condition matches.
879        let condition = ParamCondition::Any(vec![
880            ParamConditionEntry {
881                param: "operation".to_string(),
882                equals: "resize".to_string(),
883            },
884            ParamConditionEntry {
885                param: "operation".to_string(),
886                equals: "crop".to_string(),
887            },
888        ]);
889        let json = serde_json::to_string(&condition).unwrap();
890        // Should be an array of objects.
891        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
892        assert!(parsed.is_array(), "Any condition should be a JSON array");
893        assert_eq!(parsed.as_array().unwrap().len(), 2);
894        assert_eq!(parsed[0]["param"], "operation");
895        assert_eq!(parsed[0]["equals"], "resize");
896        assert_eq!(parsed[1]["equals"], "crop");
897    }
898
899    #[test]
900    fn test_parameter_def_with_ui_fields_serializes_camel_case() {
901        // When UI metadata fields are set, they should appear in JSON
902        // with camelCase keys (visibleWhen, not visible_when).
903        let param = ParameterDef {
904            name: "width".to_string(),
905            label: "Width".to_string(),
906            description: "Target width in pixels".to_string(),
907            param_type: ParameterType::Number,
908            default: None,
909            constraints: None,
910            placeholder: Some("e.g. 800".to_string()),
911            visible_when: Some(ParamCondition::Single(ParamConditionEntry {
912                param: "operation".to_string(),
913                equals: "resize".to_string(),
914            })),
915            ..Default::default()
916        };
917        let json = serde_json::to_string(&param).unwrap();
918        // "visibleWhen" should be camelCase (not "visible_when").
919        assert!(json.contains(r#""visibleWhen""#));
920        assert!(!json.contains("visible_when"));
921        // "placeholder" should be present.
922        assert!(json.contains(r#""placeholder":"e.g. 800""#));
923        // "requiredWhen" should be omitted (it's None).
924        assert!(!json.contains("requiredWhen"));
925    }
926}