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