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::{Deserialize, Serialize};
8#[cfg(feature = "ts")]
9use ts_rs::TS;
10
11/// Parameter definitions for structural/declarative node types (input,
12/// output, loop, group, parallel, transform, edit-fields).
13pub mod io_container;
14
15/// Per-category node type definitions and the aggregated registry.
16pub mod node_types;
17
18/// Parameter schema types — ParameterDef, Constraints, ParameterType, etc.
19pub mod parameters;
20
21// Re-export parameter types at this level for backward compatibility.
22pub use node_types::{all_node_types, node_type_params};
23pub use parameters::{
24    Constraints, OptionEntry, ParamCondition, ParamConditionEntry, ParameterDef, ParameterType,
25    PresetEntry,
26};
27
28// --- InputCardinality ---
29
30/// Declares how a processor expects to receive files for smart iteration.
31#[cfg_attr(feature = "ts", derive(TS))]
32#[cfg_attr(
33    feature = "ts",
34    ts(
35        export,
36        export_to = "../../../../packages/@bnto/nodes/src/generated/definitionTypes/"
37    )
38)]
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
40#[serde(rename_all = "camelCase")]
41pub enum InputCardinality {
42    /// Processes one file at a time.
43    #[default]
44    PerFile,
45    /// Needs the full batch of files at once (e.g., zip, concat, merge).
46    Batch,
47    /// Processor generates output from its parameters — no input files.
48    Source,
49}
50
51// --- NodeCategory ---
52
53/// The broad category a node belongs to. Used for UI grouping and filtering.
54#[derive(Debug, Clone, Serialize, PartialEq)]
55#[serde(rename_all = "kebab-case")]
56pub enum NodeCategory {
57    Image,
58    Spreadsheet,
59    File,
60    Data,
61    Network,
62    Control,
63    System,
64    Vector,
65    Video,
66    Io,
67}
68
69// --- NodeTypeInfo ---
70
71/// Everything the UI needs to know about a node type, independent of any
72/// specific processor/operation. The engine's authoritative type registry.
73#[derive(Debug, Clone, Serialize, PartialEq)]
74#[serde(rename_all = "camelCase")]
75pub struct NodeTypeInfo {
76    pub name: String,
77    pub label: String,
78    pub description: String,
79    pub category: NodeCategory,
80    pub is_container: bool,
81    pub platforms: Vec<String>,
82    /// Lucide icon name — consumers resolve to their own icon component.
83    pub icon: String,
84}
85
86// --- Dependency ---
87
88/// An external binary that a processor requires at runtime.
89#[cfg_attr(feature = "ts", derive(TS))]
90#[cfg_attr(
91    feature = "ts",
92    ts(
93        export,
94        export_to = "../../../../packages/@bnto/nodes/src/generated/definitionTypes/"
95    )
96)]
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
98#[serde(rename_all = "camelCase")]
99pub struct Dependency {
100    pub binary: String,
101    #[serde(default, skip_serializing_if = "String::is_empty")]
102    pub version: String,
103    pub install_hint: String,
104    #[serde(default, skip_serializing_if = "String::is_empty")]
105    pub homepage: String,
106}
107
108// --- NodeMetadata ---
109
110/// Complete self-description of a processor. Return type of
111/// `NodeProcessor::metadata()`.
112#[derive(Debug, Clone, Serialize, PartialEq)]
113#[serde(rename_all = "camelCase")]
114pub struct NodeMetadata {
115    pub node_type: std::string::String,
116    pub name: std::string::String,
117    pub description: std::string::String,
118    pub category: NodeCategory,
119    pub accepts: Vec<std::string::String>,
120    pub platforms: Vec<std::string::String>,
121    pub parameters: Vec<ParameterDef>,
122    #[serde(default)]
123    pub input_cardinality: InputCardinality,
124    #[serde(default, skip_serializing_if = "Vec::is_empty")]
125    pub requires: Vec<Dependency>,
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    // --- InputCardinality Tests ---
133
134    #[test]
135    fn test_input_cardinality_defaults_to_per_file() {
136        let cardinality = InputCardinality::default();
137        assert_eq!(cardinality, InputCardinality::PerFile);
138    }
139
140    #[test]
141    fn test_input_cardinality_serializes_camel_case() {
142        let per_file = serde_json::to_string(&InputCardinality::PerFile).unwrap();
143        assert_eq!(per_file, r#""perFile""#);
144
145        let batch = serde_json::to_string(&InputCardinality::Batch).unwrap();
146        assert_eq!(batch, r#""batch""#);
147
148        let source = serde_json::to_string(&InputCardinality::Source).unwrap();
149        assert_eq!(source, r#""source""#);
150    }
151
152    #[test]
153    fn test_metadata_with_input_cardinality_round_trip() {
154        let metadata = NodeMetadata {
155            node_type: "image-compress".to_string(),
156            name: "Compress Images".to_string(),
157            description: "Reduce image file size".to_string(),
158            category: NodeCategory::Image,
159            accepts: vec!["image/jpeg".to_string()],
160            platforms: vec!["browser".to_string()],
161            parameters: vec![],
162            input_cardinality: InputCardinality::PerFile,
163            requires: vec![],
164        };
165        let json = serde_json::to_string(&metadata).unwrap();
166        assert!(json.contains(r#""inputCardinality":"perFile""#));
167
168        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
169        assert_eq!(parsed["inputCardinality"], "perFile");
170    }
171
172    #[test]
173    fn test_metadata_with_batch_cardinality() {
174        let metadata = NodeMetadata {
175            node_type: "zip-files".to_string(),
176            name: "Zip Files".to_string(),
177            description: "Bundle files into a zip archive".to_string(),
178            category: NodeCategory::File,
179            accepts: vec![],
180            platforms: vec!["browser".to_string()],
181            parameters: vec![],
182            input_cardinality: InputCardinality::Batch,
183            requires: vec![],
184        };
185        let json = serde_json::to_string(&metadata).unwrap();
186        assert!(json.contains(r#""inputCardinality":"batch""#));
187    }
188
189    // --- Dependency Deserialization Tests ---
190
191    #[test]
192    fn test_dependency_deserializes_from_json() {
193        let json = r#"{
194            "binary": "yt-dlp",
195            "installHint": "brew install yt-dlp",
196            "homepage": "https://github.com/yt-dlp/yt-dlp"
197        }"#;
198        let dep: Dependency = serde_json::from_str(json).unwrap();
199        assert_eq!(dep.binary, "yt-dlp");
200        assert_eq!(dep.install_hint, "brew install yt-dlp");
201        assert_eq!(dep.homepage, "https://github.com/yt-dlp/yt-dlp");
202        assert!(dep.version.is_empty());
203    }
204
205    #[test]
206    fn test_dependency_deserializes_with_version() {
207        let json = r#"{
208            "binary": "ffmpeg",
209            "version": ">=6.0",
210            "installHint": "brew install ffmpeg"
211        }"#;
212        let dep: Dependency = serde_json::from_str(json).unwrap();
213        assert_eq!(dep.binary, "ffmpeg");
214        assert_eq!(dep.version, ">=6.0");
215        assert!(dep.homepage.is_empty());
216    }
217
218    #[test]
219    fn test_dependency_round_trip() {
220        let original = Dependency {
221            binary: "yt-dlp".to_string(),
222            version: ">=2024.0.0".to_string(),
223            install_hint: "brew install yt-dlp".to_string(),
224            homepage: "https://github.com/yt-dlp/yt-dlp".to_string(),
225        };
226        let json = serde_json::to_string(&original).unwrap();
227        let round_tripped: Dependency = serde_json::from_str(&json).unwrap();
228        assert_eq!(original, round_tripped);
229    }
230
231    #[test]
232    fn test_dependency_empty_optional_fields_omitted_in_serialization() {
233        let dep = Dependency {
234            binary: "curl".to_string(),
235            version: String::new(),
236            install_hint: "brew install curl".to_string(),
237            homepage: String::new(),
238        };
239        let json = serde_json::to_string(&dep).unwrap();
240        assert!(!json.contains("version"), "Empty version should be omitted");
241        assert!(
242            !json.contains("homepage"),
243            "Empty homepage should be omitted"
244        );
245        assert!(json.contains("binary"));
246        assert!(json.contains("installHint"));
247    }
248
249    // --- NodeTypeInfo Tests ---
250
251    #[test]
252    fn test_all_node_types_returns_26_entries() {
253        let types = all_node_types();
254        assert_eq!(types.len(), 26, "Should have exactly 26 node types");
255    }
256
257    #[test]
258    fn test_all_node_types_sorted_alphabetically() {
259        let types = all_node_types();
260        let names: Vec<&str> = types.iter().map(|t| t.name.as_str()).collect();
261        let mut sorted = names.clone();
262        sorted.sort();
263        assert_eq!(names, sorted, "Node types should be alphabetically sorted");
264    }
265
266    #[test]
267    fn test_all_node_types_unique_names() {
268        let types = all_node_types();
269        let mut names: Vec<&str> = types.iter().map(|t| t.name.as_str()).collect();
270        names.sort();
271        names.dedup();
272        assert_eq!(names.len(), 26, "All node type names should be unique");
273    }
274
275    #[test]
276    fn test_container_types_are_group_loop_parallel() {
277        let types = all_node_types();
278        let mut containers: Vec<&str> = types
279            .iter()
280            .filter(|t| t.is_container)
281            .map(|t| t.name.as_str())
282            .collect();
283        containers.sort();
284        assert_eq!(containers, vec!["group", "loop", "parallel"]);
285    }
286
287    #[test]
288    fn test_io_types_are_input_output() {
289        let types = all_node_types();
290        let mut io_types: Vec<&str> = types
291            .iter()
292            .filter(|t| t.category == NodeCategory::Io)
293            .map(|t| t.name.as_str())
294            .collect();
295        io_types.sort();
296        assert_eq!(io_types, vec!["input", "output"]);
297    }
298
299    #[test]
300    fn test_server_only_types() {
301        let types = all_node_types();
302        let mut server_only: Vec<&str> = types
303            .iter()
304            .filter(|t| !t.platforms.contains(&"browser".to_string()))
305            .map(|t| t.name.as_str())
306            .collect();
307        server_only.sort();
308        assert_eq!(
309            server_only,
310            vec!["file-collect", "file-copy", "http-request", "shell-command"]
311        );
312    }
313
314    #[test]
315    fn test_node_type_info_serializes_camel_case() {
316        let info = NodeTypeInfo {
317            name: "image".to_string(),
318            label: "Image".to_string(),
319            description: "Image processing".to_string(),
320            category: NodeCategory::Image,
321            is_container: false,
322            platforms: vec!["browser".to_string()],
323            icon: "image".to_string(),
324        };
325        let json = serde_json::to_string(&info).unwrap();
326        assert!(json.contains(r#""isContainer":false"#));
327        assert!(!json.contains("is_container"));
328    }
329
330    // --- Serialization Tests ---
331
332    #[test]
333    fn test_category_serializes_to_kebab_case() {
334        let json = serde_json::to_string(&NodeCategory::Image).unwrap();
335        assert_eq!(json, r#""image""#);
336
337        let json = serde_json::to_string(&NodeCategory::Spreadsheet).unwrap();
338        assert_eq!(json, r#""spreadsheet""#);
339
340        let json = serde_json::to_string(&NodeCategory::File).unwrap();
341        assert_eq!(json, r#""file""#);
342
343        let json = serde_json::to_string(&NodeCategory::Io).unwrap();
344        assert_eq!(json, r#""io""#);
345
346        let json = serde_json::to_string(&NodeCategory::Vector).unwrap();
347        assert_eq!(json, r#""vector""#);
348
349        let json = serde_json::to_string(&NodeCategory::Video).unwrap();
350        assert_eq!(json, r#""video""#);
351    }
352
353    #[test]
354    fn test_parameter_type_number_serialization() {
355        let json = serde_json::to_string(&ParameterType::Number).unwrap();
356        assert_eq!(json, r#"{"type":"number"}"#);
357    }
358
359    #[test]
360    fn test_parameter_type_enum_serialization() {
361        let param = ParameterType::Enum {
362            options: vec![
363                OptionEntry {
364                    value: "jpeg".to_string(),
365                    label: "JPEG".to_string(),
366                },
367                OptionEntry {
368                    value: "png".to_string(),
369                    label: "PNG".to_string(),
370                },
371                OptionEntry {
372                    value: "webp".to_string(),
373                    label: "WebP".to_string(),
374                },
375            ],
376        };
377        let json = serde_json::to_string(&param).unwrap();
378        assert!(json.contains(r#""type":"enum""#));
379        assert!(json.contains(r#""value":"jpeg""#));
380        assert!(json.contains(r#""label":"JPEG""#));
381        assert!(json.contains(r#""value":"webp""#));
382        assert!(json.contains(r#""label":"WebP""#));
383    }
384
385    #[test]
386    fn test_constraints_skips_none_fields() {
387        let constraints = Constraints {
388            min: Some(1.0),
389            max: None,
390            required: false,
391        };
392        let json = serde_json::to_string(&constraints).unwrap();
393        assert!(json.contains(r#""min":1.0"#));
394        assert!(!json.contains("max"));
395        assert!(json.contains(r#""required":false"#));
396    }
397
398    #[test]
399    fn test_constraints_includes_all_fields_when_present() {
400        let constraints = Constraints {
401            min: Some(1.0),
402            max: Some(100.0),
403            required: true,
404        };
405        let json = serde_json::to_string(&constraints).unwrap();
406        assert!(json.contains(r#""min":1.0"#));
407        assert!(json.contains(r#""max":100.0"#));
408        assert!(json.contains(r#""required":true"#));
409    }
410
411    #[test]
412    fn test_parameter_def_serializes_camel_case() {
413        let param = ParameterDef {
414            name: "quality".to_string(),
415            label: "Quality".to_string(),
416            description: "Compression quality".to_string(),
417            param_type: ParameterType::Number,
418            default: Some(serde_json::json!(80)),
419            constraints: Some(Constraints {
420                min: Some(1.0),
421                max: Some(100.0),
422                required: false,
423            }),
424            ..Default::default()
425        };
426        let json = serde_json::to_string(&param).unwrap();
427        assert!(json.contains(r#""paramType""#));
428        assert!(!json.contains("param_type"));
429    }
430
431    #[test]
432    fn test_parameter_def_skips_none_default() {
433        let param = ParameterDef {
434            name: "width".to_string(),
435            label: "Width".to_string(),
436            description: "Target width".to_string(),
437            param_type: ParameterType::Number,
438            ..Default::default()
439        };
440        let json = serde_json::to_string(&param).unwrap();
441        assert!(!json.contains("default"));
442        assert!(!json.contains("constraints"));
443        assert!(!json.contains("placeholder"));
444        assert!(!json.contains("visibleWhen"));
445        assert!(!json.contains("requiredWhen"));
446    }
447
448    #[test]
449    fn test_parameter_def_surfaceable_defaults_to_true() {
450        let param = ParameterDef {
451            name: "quality".to_string(),
452            label: "Quality".to_string(),
453            description: "Compression quality".to_string(),
454            param_type: ParameterType::Number,
455            ..Default::default()
456        };
457        assert!(param.surfaceable, "surfaceable should default to true");
458        let json = serde_json::to_string(&param).unwrap();
459        assert!(json.contains(r#""surfaceable":true"#));
460    }
461
462    #[test]
463    fn test_parameter_def_surfaceable_false_serializes() {
464        let param = ParameterDef {
465            name: "items".to_string(),
466            label: "Items".to_string(),
467            description: "Template expression for iteration items".to_string(),
468            param_type: ParameterType::String,
469            surfaceable: false,
470            ..Default::default()
471        };
472        assert!(!param.surfaceable);
473        let json = serde_json::to_string(&param).unwrap();
474        assert!(json.contains(r#""surfaceable":false"#));
475    }
476
477    #[test]
478    fn test_node_metadata_serializes_camel_case() {
479        let metadata = NodeMetadata {
480            node_type: "image-compress".to_string(),
481            name: "Compress Images".to_string(),
482            description: "Reduce image file size".to_string(),
483            category: NodeCategory::Image,
484            accepts: vec![
485                "image/jpeg".to_string(),
486                "image/png".to_string(),
487                "image/webp".to_string(),
488            ],
489            platforms: vec!["browser".to_string()],
490            parameters: vec![],
491            input_cardinality: InputCardinality::PerFile,
492            requires: vec![],
493        };
494        let json = serde_json::to_string(&metadata).unwrap();
495        assert!(json.contains(r#""nodeType":"image-compress""#));
496        assert!(json.contains(r#""platforms":["browser"]"#));
497        assert!(!json.contains("node_type"));
498    }
499
500    #[test]
501    fn test_full_metadata_round_trip() {
502        let metadata = NodeMetadata {
503            node_type: "image-compress".to_string(),
504            name: "Compress Images".to_string(),
505            description: "Reduce image file size while maintaining quality".to_string(),
506            category: NodeCategory::Image,
507            accepts: vec![
508                "image/jpeg".to_string(),
509                "image/png".to_string(),
510                "image/webp".to_string(),
511            ],
512            platforms: vec!["browser".to_string()],
513            parameters: vec![ParameterDef {
514                name: "quality".to_string(),
515                label: "Quality".to_string(),
516                description: "Compression quality (1-100)".to_string(),
517                param_type: ParameterType::Number,
518                default: Some(serde_json::json!(80)),
519                constraints: Some(Constraints {
520                    min: Some(1.0),
521                    max: Some(100.0),
522                    required: false,
523                }),
524                ..Default::default()
525            }],
526            input_cardinality: InputCardinality::PerFile,
527            requires: vec![],
528        };
529
530        let json = serde_json::to_string_pretty(&metadata).unwrap();
531        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
532
533        assert_eq!(parsed["nodeType"], "image-compress");
534        assert_eq!(parsed["category"], "image");
535        assert_eq!(parsed["platforms"][0], "browser");
536        assert_eq!(parsed["accepts"].as_array().unwrap().len(), 3);
537        assert_eq!(parsed["parameters"].as_array().unwrap().len(), 1);
538        assert_eq!(parsed["parameters"][0]["name"], "quality");
539        assert_eq!(parsed["parameters"][0]["default"], 80);
540    }
541
542    // --- ParamCondition Serialization Tests ---
543
544    #[test]
545    fn test_param_condition_single_serializes_as_object() {
546        let condition = ParamCondition::Single(ParamConditionEntry {
547            param: "operation".to_string(),
548            equals: "resize".to_string(),
549        });
550        let json = serde_json::to_string(&condition).unwrap();
551        assert_eq!(json, r#"{"param":"operation","equals":"resize"}"#);
552    }
553
554    #[test]
555    fn test_param_condition_any_serializes_as_array() {
556        let condition = ParamCondition::Any(vec![
557            ParamConditionEntry {
558                param: "operation".to_string(),
559                equals: "resize".to_string(),
560            },
561            ParamConditionEntry {
562                param: "operation".to_string(),
563                equals: "crop".to_string(),
564            },
565        ]);
566        let json = serde_json::to_string(&condition).unwrap();
567        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
568        assert!(parsed.is_array(), "Any condition should be a JSON array");
569        assert_eq!(parsed.as_array().unwrap().len(), 2);
570        assert_eq!(parsed[0]["param"], "operation");
571        assert_eq!(parsed[0]["equals"], "resize");
572        assert_eq!(parsed[1]["equals"], "crop");
573    }
574
575    #[test]
576    fn test_parameter_def_with_ui_fields_serializes_camel_case() {
577        let param = ParameterDef {
578            name: "width".to_string(),
579            label: "Width".to_string(),
580            description: "Target width in pixels".to_string(),
581            param_type: ParameterType::Number,
582            default: None,
583            constraints: None,
584            placeholder: Some("e.g. 800".to_string()),
585            visible_when: Some(ParamCondition::Single(ParamConditionEntry {
586                param: "operation".to_string(),
587                equals: "resize".to_string(),
588            })),
589            ..Default::default()
590        };
591        let json = serde_json::to_string(&param).unwrap();
592        assert!(json.contains(r#""visibleWhen""#));
593        assert!(!json.contains("visible_when"));
594        assert!(json.contains(r#""placeholder":"e.g. 800""#));
595        assert!(!json.contains("requiredWhen"));
596    }
597
598    // --- Dependency Tests ---
599
600    #[test]
601    fn test_dependency_serializes_camel_case() {
602        let dep = Dependency {
603            binary: "yt-dlp".to_string(),
604            version: ">=2023.01.01".to_string(),
605            install_hint: "brew install yt-dlp".to_string(),
606            homepage: "https://github.com/yt-dlp/yt-dlp".to_string(),
607        };
608        let json = serde_json::to_string(&dep).unwrap();
609        assert!(json.contains(r#""binary":"yt-dlp""#));
610        assert!(json.contains(r#""version":">=2023.01.01""#));
611        assert!(json.contains(r#""installHint":"brew install yt-dlp""#));
612        assert!(json.contains(r#""homepage":"https://github.com/yt-dlp/yt-dlp""#));
613        assert!(!json.contains("install_hint"));
614    }
615
616    #[test]
617    fn test_dependency_skips_empty_optional_fields() {
618        let dep = Dependency {
619            binary: "ffmpeg".to_string(),
620            version: String::new(),
621            install_hint: "brew install ffmpeg".to_string(),
622            homepage: String::new(),
623        };
624        let json = serde_json::to_string(&dep).unwrap();
625        assert!(!json.contains("version"), "empty version should be omitted");
626        assert!(
627            !json.contains("homepage"),
628            "empty homepage should be omitted"
629        );
630        assert!(json.contains(r#""binary":"ffmpeg""#));
631        assert!(json.contains(r#""installHint""#));
632    }
633
634    #[test]
635    fn test_dependency_equality() {
636        let a = Dependency {
637            binary: "yt-dlp".to_string(),
638            version: ">=2023.01.01".to_string(),
639            install_hint: "brew install yt-dlp".to_string(),
640            homepage: "https://github.com/yt-dlp/yt-dlp".to_string(),
641        };
642        let b = a.clone();
643        assert_eq!(a, b);
644    }
645
646    #[test]
647    fn test_metadata_requires_empty_skipped_in_serialization() {
648        let metadata = NodeMetadata {
649            node_type: "image-compress".to_string(),
650            name: "Compress".to_string(),
651            description: String::new(),
652            category: NodeCategory::Image,
653            accepts: vec![],
654            platforms: vec!["browser".to_string()],
655            parameters: vec![],
656            input_cardinality: InputCardinality::PerFile,
657            requires: vec![],
658        };
659        let json = serde_json::to_string(&metadata).unwrap();
660        assert!(
661            !json.contains("requires"),
662            "empty requires should be omitted"
663        );
664    }
665
666    #[test]
667    fn test_metadata_requires_present_when_populated() {
668        let metadata = NodeMetadata {
669            node_type: "video-download".to_string(),
670            name: "Download Video".to_string(),
671            description: String::new(),
672            category: NodeCategory::Network,
673            accepts: vec![],
674            platforms: vec!["server".to_string()],
675            parameters: vec![],
676            input_cardinality: InputCardinality::PerFile,
677            requires: vec![Dependency {
678                binary: "yt-dlp".to_string(),
679                version: ">=2023.01.01".to_string(),
680                install_hint: "brew install yt-dlp".to_string(),
681                homepage: "https://github.com/yt-dlp/yt-dlp".to_string(),
682            }],
683        };
684        let json = serde_json::to_string(&metadata).unwrap();
685        assert!(json.contains(r#""requires""#));
686        assert!(json.contains(r#""binary":"yt-dlp""#));
687        assert!(json.contains(r#""version":">=2023.01.01""#));
688    }
689
690    // --- Presentation Metadata Tests ---
691
692    #[test]
693    fn test_preset_entry_serializes_value_and_label() {
694        let preset = PresetEntry {
695            value: serde_json::json!(80),
696            label: "Balanced".to_string(),
697        };
698        let json = serde_json::to_string(&preset).unwrap();
699        assert!(json.contains(r#""value":80"#));
700        assert!(json.contains(r#""label":"Balanced""#));
701    }
702
703    #[test]
704    fn test_preset_entry_accepts_heterogeneous_values() {
705        let string_preset = PresetEntry {
706            value: serde_json::json!("jpeg"),
707            label: "JPEG".to_string(),
708        };
709        let json = serde_json::to_string(&string_preset).unwrap();
710        assert!(json.contains(r#""value":"jpeg""#));
711    }
712
713    #[test]
714    fn test_option_entry_serializes_value_and_label() {
715        let option = OptionEntry {
716            value: "snake".to_string(),
717            label: "snake_case".to_string(),
718        };
719        let json = serde_json::to_string(&option).unwrap();
720        assert_eq!(json, r#"{"value":"snake","label":"snake_case"}"#);
721    }
722
723    #[test]
724    fn test_parameter_def_presets_round_trip() {
725        let param = ParameterDef {
726            name: "quality".to_string(),
727            label: "Quality".to_string(),
728            description: "Compression quality".to_string(),
729            param_type: ParameterType::Number,
730            presets: Some(vec![
731                PresetEntry {
732                    value: serde_json::json!(60),
733                    label: "Draft".to_string(),
734                },
735                PresetEntry {
736                    value: serde_json::json!(80),
737                    label: "Balanced".to_string(),
738                },
739                PresetEntry {
740                    value: serde_json::json!(100),
741                    label: "Maximum".to_string(),
742                },
743            ]),
744            ..Default::default()
745        };
746        let json = serde_json::to_string(&param).unwrap();
747        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
748        let presets = parsed["presets"].as_array().unwrap();
749        assert_eq!(presets.len(), 3);
750        assert_eq!(presets[0]["value"], 60);
751        assert_eq!(presets[0]["label"], "Draft");
752        assert_eq!(presets[1]["label"], "Balanced");
753        assert_eq!(presets[2]["value"], 100);
754    }
755
756    #[test]
757    fn test_parameter_def_group_and_suffix_round_trip() {
758        let param = ParameterDef {
759            name: "width".to_string(),
760            label: "Width".to_string(),
761            description: "Target width".to_string(),
762            param_type: ParameterType::Number,
763            group: Some("dimensions".to_string()),
764            suffix: Some("px".to_string()),
765            ..Default::default()
766        };
767        let json = serde_json::to_string(&param).unwrap();
768        assert!(json.contains(r#""group":"dimensions""#));
769        assert!(json.contains(r#""suffix":"px""#));
770    }
771
772    #[test]
773    fn test_parameter_def_control_and_accept_round_trip() {
774        let param = ParameterDef {
775            name: "image".to_string(),
776            label: "Watermark image".to_string(),
777            description: "Image to overlay".to_string(),
778            param_type: ParameterType::String,
779            control: Some("file".to_string()),
780            accept: Some(vec!["image/*".to_string()]),
781            ..Default::default()
782        };
783        let json = serde_json::to_string(&param).unwrap();
784        assert!(json.contains(r#""control":"file""#));
785        assert!(json.contains(r#""accept":["image/*"]"#));
786    }
787
788    #[test]
789    fn test_parameter_def_control_without_accept() {
790        let param = ParameterDef {
791            name: "preview".to_string(),
792            label: "Preview".to_string(),
793            description: "Watermark preview".to_string(),
794            param_type: ParameterType::String,
795            control: Some("watermarkPreview".to_string()),
796            ..Default::default()
797        };
798        let json = serde_json::to_string(&param).unwrap();
799        assert!(json.contains(r#""control":"watermarkPreview""#));
800        assert!(!json.contains("accept"));
801    }
802
803    #[test]
804    fn test_parameter_def_inverted_round_trip() {
805        let param = ParameterDef {
806            name: "stripExif".to_string(),
807            label: "Keep metadata".to_string(),
808            description: "Preserve EXIF metadata".to_string(),
809            param_type: ParameterType::Boolean,
810            inverted: Some(true),
811            ..Default::default()
812        };
813        let json = serde_json::to_string(&param).unwrap();
814        assert!(json.contains(r#""inverted":true"#));
815    }
816
817    #[test]
818    fn test_parameter_def_new_fields_skip_none() {
819        let param = ParameterDef {
820            name: "quality".to_string(),
821            label: "Quality".to_string(),
822            description: "Compression quality".to_string(),
823            param_type: ParameterType::Number,
824            ..Default::default()
825        };
826        let json = serde_json::to_string(&param).unwrap();
827        assert!(!json.contains("\"group\""));
828        assert!(!json.contains("\"suffix\""));
829        assert!(!json.contains("\"control\""));
830        assert!(!json.contains("\"accept\""));
831        assert!(!json.contains("\"presets\""));
832        assert!(!json.contains("\"inverted\""));
833    }
834
835    #[test]
836    fn test_parameter_def_default_new_fields_are_none() {
837        let param = ParameterDef::default();
838        assert!(param.group.is_none());
839        assert!(param.suffix.is_none());
840        assert!(param.control.is_none());
841        assert!(param.accept.is_none());
842        assert!(param.presets.is_none());
843        assert!(param.inverted.is_none());
844    }
845
846    #[test]
847    fn test_parameter_def_new_fields_use_camel_case() {
848        let param = ParameterDef {
849            name: "image".to_string(),
850            label: "Image".to_string(),
851            description: "Overlay image".to_string(),
852            param_type: ParameterType::String,
853            control: Some("file".to_string()),
854            accept: Some(vec!["image/png".to_string()]),
855            group: Some("media".to_string()),
856            suffix: Some("%".to_string()),
857            inverted: Some(false),
858            presets: Some(vec![PresetEntry {
859                value: serde_json::json!(80),
860                label: "Balanced".to_string(),
861            }]),
862            ..Default::default()
863        };
864        let json = serde_json::to_string(&param).unwrap();
865        for key in [
866            "control", "accept", "group", "suffix", "inverted", "presets",
867        ] {
868            let needle = format!(r#""{key}""#);
869            assert!(
870                json.contains(&needle),
871                "expected serialized param to contain {needle}; got: {json}"
872            );
873        }
874    }
875}