1use serde::{Deserialize, Serialize};
8#[cfg(feature = "ts")]
9use ts_rs::TS;
10
11pub mod io_container;
14
15pub mod install_hints;
17
18pub mod node_types;
20
21pub mod parameters;
23
24pub use install_hints::{HostPlatform, InstallHints};
25pub use node_types::{all_node_types, node_type_params};
27pub use parameters::{
28 Constraints, OptionEntry, ParamCondition, ParamConditionEntry, ParameterDef, ParameterType,
29 PresetEntry,
30};
31
32#[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 #[default]
48 PerFile,
49 Batch,
51 Source,
53}
54
55#[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#[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 pub icon: String,
88}
89
90#[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 #[serde(default, skip_serializing_if = "String::is_empty")]
110 pub install_hint: String,
111 #[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
118pub const GENERIC_INSTALL_HINT: &str =
121 "no install hint available; install it and make sure it is on your PATH";
122
123impl Dependency {
124 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 pub fn host_install_hint(&self) -> &str {
135 self.install_hint_for(HostPlatform::current())
136 }
137}
138
139#[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 #[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 #[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 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 #[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 #[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(¶m).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(¶m).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(¶m).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(¶m).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(¶m).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 #[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(¶m).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 #[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 #[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(¶m).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(¶m).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(¶m).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(¶m).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(¶m).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(¶m).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(¶m).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 #[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}