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_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 #[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(¶m).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(¶m).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(¶m).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(¶m).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(¶m).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 #[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(¶m).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 #[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 #[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(¶m).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(¶m).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(¶m).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(¶m).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(¶m).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(¶m).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(¶m).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 #[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}