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