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-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_26_entries() {
337        let types = all_node_types();
338        assert_eq!(types.len(), 26, "Should have exactly 26 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(), 26, "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!["file-collect", "file-copy", "http-request", "shell-command"]
395        );
396    }
397
398    #[test]
399    fn test_node_type_info_serializes_camel_case() {
400        let info = NodeTypeInfo {
401            name: "image".to_string(),
402            label: "Image".to_string(),
403            description: "Image processing".to_string(),
404            category: NodeCategory::Image,
405            is_container: false,
406            platforms: vec!["browser".to_string()],
407            icon: "image".to_string(),
408        };
409        let json = serde_json::to_string(&info).unwrap();
410        assert!(json.contains(r#""isContainer":false"#));
411        assert!(!json.contains("is_container"));
412    }
413
414    // --- Serialization Tests ---
415
416    #[test]
417    fn test_category_serializes_to_kebab_case() {
418        let json = serde_json::to_string(&NodeCategory::Image).unwrap();
419        assert_eq!(json, r#""image""#);
420
421        let json = serde_json::to_string(&NodeCategory::Spreadsheet).unwrap();
422        assert_eq!(json, r#""spreadsheet""#);
423
424        let json = serde_json::to_string(&NodeCategory::File).unwrap();
425        assert_eq!(json, r#""file""#);
426
427        let json = serde_json::to_string(&NodeCategory::Io).unwrap();
428        assert_eq!(json, r#""io""#);
429
430        let json = serde_json::to_string(&NodeCategory::Vector).unwrap();
431        assert_eq!(json, r#""vector""#);
432
433        let json = serde_json::to_string(&NodeCategory::Video).unwrap();
434        assert_eq!(json, r#""video""#);
435    }
436
437    #[test]
438    fn test_parameter_type_number_serialization() {
439        let json = serde_json::to_string(&ParameterType::Number).unwrap();
440        assert_eq!(json, r#"{"type":"number"}"#);
441    }
442
443    #[test]
444    fn test_parameter_type_enum_serialization() {
445        let param = ParameterType::Enum {
446            options: vec![
447                OptionEntry {
448                    value: "jpeg".to_string(),
449                    label: "JPEG".to_string(),
450                },
451                OptionEntry {
452                    value: "png".to_string(),
453                    label: "PNG".to_string(),
454                },
455                OptionEntry {
456                    value: "webp".to_string(),
457                    label: "WebP".to_string(),
458                },
459            ],
460        };
461        let json = serde_json::to_string(&param).unwrap();
462        assert!(json.contains(r#""type":"enum""#));
463        assert!(json.contains(r#""value":"jpeg""#));
464        assert!(json.contains(r#""label":"JPEG""#));
465        assert!(json.contains(r#""value":"webp""#));
466        assert!(json.contains(r#""label":"WebP""#));
467    }
468
469    #[test]
470    fn test_constraints_skips_none_fields() {
471        let constraints = Constraints {
472            min: Some(1.0),
473            max: None,
474            required: false,
475        };
476        let json = serde_json::to_string(&constraints).unwrap();
477        assert!(json.contains(r#""min":1.0"#));
478        assert!(!json.contains("max"));
479        assert!(json.contains(r#""required":false"#));
480    }
481
482    #[test]
483    fn test_constraints_includes_all_fields_when_present() {
484        let constraints = Constraints {
485            min: Some(1.0),
486            max: Some(100.0),
487            required: true,
488        };
489        let json = serde_json::to_string(&constraints).unwrap();
490        assert!(json.contains(r#""min":1.0"#));
491        assert!(json.contains(r#""max":100.0"#));
492        assert!(json.contains(r#""required":true"#));
493    }
494
495    #[test]
496    fn test_parameter_def_serializes_camel_case() {
497        let param = ParameterDef {
498            name: "quality".to_string(),
499            label: "Quality".to_string(),
500            description: "Compression quality".to_string(),
501            param_type: ParameterType::Number,
502            default: Some(serde_json::json!(80)),
503            constraints: Some(Constraints {
504                min: Some(1.0),
505                max: Some(100.0),
506                required: false,
507            }),
508            ..Default::default()
509        };
510        let json = serde_json::to_string(&param).unwrap();
511        assert!(json.contains(r#""paramType""#));
512        assert!(!json.contains("param_type"));
513    }
514
515    #[test]
516    fn test_parameter_def_skips_none_default() {
517        let param = ParameterDef {
518            name: "width".to_string(),
519            label: "Width".to_string(),
520            description: "Target width".to_string(),
521            param_type: ParameterType::Number,
522            ..Default::default()
523        };
524        let json = serde_json::to_string(&param).unwrap();
525        assert!(!json.contains("default"));
526        assert!(!json.contains("constraints"));
527        assert!(!json.contains("placeholder"));
528        assert!(!json.contains("visibleWhen"));
529        assert!(!json.contains("requiredWhen"));
530    }
531
532    #[test]
533    fn test_parameter_def_surfaceable_defaults_to_true() {
534        let param = ParameterDef {
535            name: "quality".to_string(),
536            label: "Quality".to_string(),
537            description: "Compression quality".to_string(),
538            param_type: ParameterType::Number,
539            ..Default::default()
540        };
541        assert!(param.surfaceable, "surfaceable should default to true");
542        let json = serde_json::to_string(&param).unwrap();
543        assert!(json.contains(r#""surfaceable":true"#));
544    }
545
546    #[test]
547    fn test_parameter_def_surfaceable_false_serializes() {
548        let param = ParameterDef {
549            name: "items".to_string(),
550            label: "Items".to_string(),
551            description: "Template expression for iteration items".to_string(),
552            param_type: ParameterType::String,
553            surfaceable: false,
554            ..Default::default()
555        };
556        assert!(!param.surfaceable);
557        let json = serde_json::to_string(&param).unwrap();
558        assert!(json.contains(r#""surfaceable":false"#));
559    }
560
561    #[test]
562    fn test_node_metadata_serializes_camel_case() {
563        let metadata = NodeMetadata {
564            node_type: "image-compress".to_string(),
565            name: "Compress Images".to_string(),
566            description: "Reduce image file size".to_string(),
567            category: NodeCategory::Image,
568            accepts: vec![
569                "image/jpeg".to_string(),
570                "image/png".to_string(),
571                "image/webp".to_string(),
572            ],
573            platforms: vec!["browser".to_string()],
574            parameters: vec![],
575            input_cardinality: InputCardinality::PerFile,
576            requires: vec![],
577        };
578        let json = serde_json::to_string(&metadata).unwrap();
579        assert!(json.contains(r#""nodeType":"image-compress""#));
580        assert!(json.contains(r#""platforms":["browser"]"#));
581        assert!(!json.contains("node_type"));
582    }
583
584    #[test]
585    fn test_full_metadata_round_trip() {
586        let metadata = NodeMetadata {
587            node_type: "image-compress".to_string(),
588            name: "Compress Images".to_string(),
589            description: "Reduce image file size while maintaining quality".to_string(),
590            category: NodeCategory::Image,
591            accepts: vec![
592                "image/jpeg".to_string(),
593                "image/png".to_string(),
594                "image/webp".to_string(),
595            ],
596            platforms: vec!["browser".to_string()],
597            parameters: vec![ParameterDef {
598                name: "quality".to_string(),
599                label: "Quality".to_string(),
600                description: "Compression quality (1-100)".to_string(),
601                param_type: ParameterType::Number,
602                default: Some(serde_json::json!(80)),
603                constraints: Some(Constraints {
604                    min: Some(1.0),
605                    max: Some(100.0),
606                    required: false,
607                }),
608                ..Default::default()
609            }],
610            input_cardinality: InputCardinality::PerFile,
611            requires: vec![],
612        };
613
614        let json = serde_json::to_string_pretty(&metadata).unwrap();
615        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
616
617        assert_eq!(parsed["nodeType"], "image-compress");
618        assert_eq!(parsed["category"], "image");
619        assert_eq!(parsed["platforms"][0], "browser");
620        assert_eq!(parsed["accepts"].as_array().unwrap().len(), 3);
621        assert_eq!(parsed["parameters"].as_array().unwrap().len(), 1);
622        assert_eq!(parsed["parameters"][0]["name"], "quality");
623        assert_eq!(parsed["parameters"][0]["default"], 80);
624    }
625
626    // --- ParamCondition Serialization Tests ---
627
628    #[test]
629    fn test_param_condition_single_serializes_as_object() {
630        let condition = ParamCondition::Single(ParamConditionEntry {
631            param: "operation".to_string(),
632            equals: "resize".to_string(),
633        });
634        let json = serde_json::to_string(&condition).unwrap();
635        assert_eq!(json, r#"{"param":"operation","equals":"resize"}"#);
636    }
637
638    #[test]
639    fn test_param_condition_any_serializes_as_array() {
640        let condition = ParamCondition::Any(vec![
641            ParamConditionEntry {
642                param: "operation".to_string(),
643                equals: "resize".to_string(),
644            },
645            ParamConditionEntry {
646                param: "operation".to_string(),
647                equals: "crop".to_string(),
648            },
649        ]);
650        let json = serde_json::to_string(&condition).unwrap();
651        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
652        assert!(parsed.is_array(), "Any condition should be a JSON array");
653        assert_eq!(parsed.as_array().unwrap().len(), 2);
654        assert_eq!(parsed[0]["param"], "operation");
655        assert_eq!(parsed[0]["equals"], "resize");
656        assert_eq!(parsed[1]["equals"], "crop");
657    }
658
659    #[test]
660    fn test_parameter_def_with_ui_fields_serializes_camel_case() {
661        let param = ParameterDef {
662            name: "width".to_string(),
663            label: "Width".to_string(),
664            description: "Target width in pixels".to_string(),
665            param_type: ParameterType::Number,
666            default: None,
667            constraints: None,
668            placeholder: Some("e.g. 800".to_string()),
669            visible_when: Some(ParamCondition::Single(ParamConditionEntry {
670                param: "operation".to_string(),
671                equals: "resize".to_string(),
672            })),
673            ..Default::default()
674        };
675        let json = serde_json::to_string(&param).unwrap();
676        assert!(json.contains(r#""visibleWhen""#));
677        assert!(!json.contains("visible_when"));
678        assert!(json.contains(r#""placeholder":"e.g. 800""#));
679        assert!(!json.contains("requiredWhen"));
680    }
681
682    // --- Dependency Tests ---
683
684    #[test]
685    fn test_dependency_serializes_camel_case() {
686        let dep = Dependency {
687            binary: "yt-dlp".to_string(),
688            version: ">=2023.01.01".to_string(),
689            install_hint: "brew install yt-dlp".to_string(),
690            install_hints: Default::default(),
691            homepage: "https://github.com/yt-dlp/yt-dlp".to_string(),
692        };
693        let json = serde_json::to_string(&dep).unwrap();
694        assert!(json.contains(r#""binary":"yt-dlp""#));
695        assert!(json.contains(r#""version":">=2023.01.01""#));
696        assert!(json.contains(r#""installHint":"brew install yt-dlp""#));
697        assert!(json.contains(r#""homepage":"https://github.com/yt-dlp/yt-dlp""#));
698        assert!(!json.contains("install_hint"));
699    }
700
701    #[test]
702    fn test_dependency_skips_empty_optional_fields() {
703        let dep = Dependency {
704            binary: "ffmpeg".to_string(),
705            version: String::new(),
706            install_hint: "brew install ffmpeg".to_string(),
707            install_hints: Default::default(),
708            homepage: String::new(),
709        };
710        let json = serde_json::to_string(&dep).unwrap();
711        assert!(!json.contains("version"), "empty version should be omitted");
712        assert!(
713            !json.contains("homepage"),
714            "empty homepage should be omitted"
715        );
716        assert!(json.contains(r#""binary":"ffmpeg""#));
717        assert!(json.contains(r#""installHint""#));
718    }
719
720    #[test]
721    fn test_dependency_equality() {
722        let a = Dependency {
723            binary: "yt-dlp".to_string(),
724            version: ">=2023.01.01".to_string(),
725            install_hint: "brew install yt-dlp".to_string(),
726            install_hints: Default::default(),
727            homepage: "https://github.com/yt-dlp/yt-dlp".to_string(),
728        };
729        let b = a.clone();
730        assert_eq!(a, b);
731    }
732
733    #[test]
734    fn test_metadata_requires_empty_skipped_in_serialization() {
735        let metadata = NodeMetadata {
736            node_type: "image-compress".to_string(),
737            name: "Compress".to_string(),
738            description: String::new(),
739            category: NodeCategory::Image,
740            accepts: vec![],
741            platforms: vec!["browser".to_string()],
742            parameters: vec![],
743            input_cardinality: InputCardinality::PerFile,
744            requires: vec![],
745        };
746        let json = serde_json::to_string(&metadata).unwrap();
747        assert!(
748            !json.contains("requires"),
749            "empty requires should be omitted"
750        );
751    }
752
753    #[test]
754    fn test_metadata_requires_present_when_populated() {
755        let metadata = NodeMetadata {
756            node_type: "video-download".to_string(),
757            name: "Download Video".to_string(),
758            description: String::new(),
759            category: NodeCategory::Network,
760            accepts: vec![],
761            platforms: vec!["server".to_string()],
762            parameters: vec![],
763            input_cardinality: InputCardinality::PerFile,
764            requires: vec![Dependency {
765                binary: "yt-dlp".to_string(),
766                version: ">=2023.01.01".to_string(),
767                install_hint: "brew install yt-dlp".to_string(),
768                install_hints: Default::default(),
769                homepage: "https://github.com/yt-dlp/yt-dlp".to_string(),
770            }],
771        };
772        let json = serde_json::to_string(&metadata).unwrap();
773        assert!(json.contains(r#""requires""#));
774        assert!(json.contains(r#""binary":"yt-dlp""#));
775        assert!(json.contains(r#""version":">=2023.01.01""#));
776    }
777
778    // --- Presentation Metadata Tests ---
779
780    #[test]
781    fn test_preset_entry_serializes_value_and_label() {
782        let preset = PresetEntry {
783            value: serde_json::json!(80),
784            label: "Balanced".to_string(),
785        };
786        let json = serde_json::to_string(&preset).unwrap();
787        assert!(json.contains(r#""value":80"#));
788        assert!(json.contains(r#""label":"Balanced""#));
789    }
790
791    #[test]
792    fn test_preset_entry_accepts_heterogeneous_values() {
793        let string_preset = PresetEntry {
794            value: serde_json::json!("jpeg"),
795            label: "JPEG".to_string(),
796        };
797        let json = serde_json::to_string(&string_preset).unwrap();
798        assert!(json.contains(r#""value":"jpeg""#));
799    }
800
801    #[test]
802    fn test_option_entry_serializes_value_and_label() {
803        let option = OptionEntry {
804            value: "snake".to_string(),
805            label: "snake_case".to_string(),
806        };
807        let json = serde_json::to_string(&option).unwrap();
808        assert_eq!(json, r#"{"value":"snake","label":"snake_case"}"#);
809    }
810
811    #[test]
812    fn test_parameter_def_presets_round_trip() {
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            presets: Some(vec![
819                PresetEntry {
820                    value: serde_json::json!(60),
821                    label: "Draft".to_string(),
822                },
823                PresetEntry {
824                    value: serde_json::json!(80),
825                    label: "Balanced".to_string(),
826                },
827                PresetEntry {
828                    value: serde_json::json!(100),
829                    label: "Maximum".to_string(),
830                },
831            ]),
832            ..Default::default()
833        };
834        let json = serde_json::to_string(&param).unwrap();
835        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
836        let presets = parsed["presets"].as_array().unwrap();
837        assert_eq!(presets.len(), 3);
838        assert_eq!(presets[0]["value"], 60);
839        assert_eq!(presets[0]["label"], "Draft");
840        assert_eq!(presets[1]["label"], "Balanced");
841        assert_eq!(presets[2]["value"], 100);
842    }
843
844    #[test]
845    fn test_parameter_def_group_and_suffix_round_trip() {
846        let param = ParameterDef {
847            name: "width".to_string(),
848            label: "Width".to_string(),
849            description: "Target width".to_string(),
850            param_type: ParameterType::Number,
851            group: Some("dimensions".to_string()),
852            suffix: Some("px".to_string()),
853            ..Default::default()
854        };
855        let json = serde_json::to_string(&param).unwrap();
856        assert!(json.contains(r#""group":"dimensions""#));
857        assert!(json.contains(r#""suffix":"px""#));
858    }
859
860    #[test]
861    fn test_parameter_def_control_and_accept_round_trip() {
862        let param = ParameterDef {
863            name: "image".to_string(),
864            label: "Watermark image".to_string(),
865            description: "Image to overlay".to_string(),
866            param_type: ParameterType::String,
867            control: Some("file".to_string()),
868            accept: Some(vec!["image/*".to_string()]),
869            ..Default::default()
870        };
871        let json = serde_json::to_string(&param).unwrap();
872        assert!(json.contains(r#""control":"file""#));
873        assert!(json.contains(r#""accept":["image/*"]"#));
874    }
875
876    #[test]
877    fn test_parameter_def_control_without_accept() {
878        let param = ParameterDef {
879            name: "preview".to_string(),
880            label: "Preview".to_string(),
881            description: "Watermark preview".to_string(),
882            param_type: ParameterType::String,
883            control: Some("watermarkPreview".to_string()),
884            ..Default::default()
885        };
886        let json = serde_json::to_string(&param).unwrap();
887        assert!(json.contains(r#""control":"watermarkPreview""#));
888        assert!(!json.contains("accept"));
889    }
890
891    #[test]
892    fn test_parameter_def_inverted_round_trip() {
893        let param = ParameterDef {
894            name: "stripExif".to_string(),
895            label: "Keep metadata".to_string(),
896            description: "Preserve EXIF metadata".to_string(),
897            param_type: ParameterType::Boolean,
898            inverted: Some(true),
899            ..Default::default()
900        };
901        let json = serde_json::to_string(&param).unwrap();
902        assert!(json.contains(r#""inverted":true"#));
903    }
904
905    #[test]
906    fn test_parameter_def_new_fields_skip_none() {
907        let param = ParameterDef {
908            name: "quality".to_string(),
909            label: "Quality".to_string(),
910            description: "Compression quality".to_string(),
911            param_type: ParameterType::Number,
912            ..Default::default()
913        };
914        let json = serde_json::to_string(&param).unwrap();
915        assert!(!json.contains("\"group\""));
916        assert!(!json.contains("\"suffix\""));
917        assert!(!json.contains("\"control\""));
918        assert!(!json.contains("\"accept\""));
919        assert!(!json.contains("\"presets\""));
920        assert!(!json.contains("\"inverted\""));
921    }
922
923    #[test]
924    fn test_parameter_def_default_new_fields_are_none() {
925        let param = ParameterDef::default();
926        assert!(param.group.is_none());
927        assert!(param.suffix.is_none());
928        assert!(param.control.is_none());
929        assert!(param.accept.is_none());
930        assert!(param.presets.is_none());
931        assert!(param.inverted.is_none());
932    }
933
934    #[test]
935    fn test_parameter_def_new_fields_use_camel_case() {
936        let param = ParameterDef {
937            name: "image".to_string(),
938            label: "Image".to_string(),
939            description: "Overlay image".to_string(),
940            param_type: ParameterType::String,
941            control: Some("file".to_string()),
942            accept: Some(vec!["image/png".to_string()]),
943            group: Some("media".to_string()),
944            suffix: Some("%".to_string()),
945            inverted: Some(false),
946            presets: Some(vec![PresetEntry {
947                value: serde_json::json!(80),
948                label: "Balanced".to_string(),
949            }]),
950            ..Default::default()
951        };
952        let json = serde_json::to_string(&param).unwrap();
953        for key in [
954            "control", "accept", "group", "suffix", "inverted", "presets",
955        ] {
956            let needle = format!(r#""{key}""#);
957            assert!(
958                json.contains(&needle),
959                "expected serialized param to contain {needle}; got: {json}"
960            );
961        }
962    }
963
964    // --- Dependency install hints ---
965
966    #[test]
967    fn dependency_with_only_platform_hints_deserializes() {
968        let dep: Dependency = serde_json::from_str(
969            r#"{ "binary": "yt-dlp", "installHints": { "macos": "brew install yt-dlp" } }"#,
970        )
971        .unwrap();
972        assert!(dep.install_hint.is_empty());
973        assert_eq!(dep.install_hints.macos, "brew install yt-dlp");
974        assert_eq!(
975            dep.install_hint_for(Some(HostPlatform::MacOs)),
976            "brew install yt-dlp"
977        );
978    }
979
980    #[test]
981    fn dependency_without_any_hint_falls_back_to_generic_message() {
982        let dep: Dependency = serde_json::from_str(
983            r#"{ "binary": "yt-dlp", "installHints": { "macos": "brew install yt-dlp" } }"#,
984        )
985        .unwrap();
986        assert_eq!(
987            dep.install_hint_for(Some(HostPlatform::Windows)),
988            GENERIC_INSTALL_HINT
989        );
990        assert_eq!(dep.install_hint_for(None), GENERIC_INSTALL_HINT);
991    }
992
993    #[test]
994    fn dependency_omits_empty_install_hint_when_serialized() {
995        let dep: Dependency = serde_json::from_str(r#"{ "binary": "git" }"#).unwrap();
996        let json = serde_json::to_string(&dep).unwrap();
997        assert_eq!(json, r#"{"binary":"git"}"#);
998    }
999}