1use serde::Serialize;
8
9#[derive(Debug, Clone, Serialize, PartialEq)]
19#[serde(rename_all = "camelCase")]
20pub struct ParamConditionEntry {
21 pub param: String,
24
25 pub equals: String,
28}
29
30#[derive(Debug, Clone, Serialize, PartialEq)]
36#[serde(untagged)]
37pub enum ParamCondition {
38 Single(ParamConditionEntry),
41
42 Any(Vec<ParamConditionEntry>),
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
53#[serde(rename_all = "camelCase")]
54pub enum InputCardinality {
55 #[default]
58 PerFile,
59 Batch,
62 Source,
65}
66
67#[derive(Debug, Clone, Serialize, PartialEq)]
72#[serde(rename_all = "kebab-case")]
73pub enum NodeCategory {
74 Image,
76 Spreadsheet,
78 File,
80 Data,
82 Network,
84 Control,
86 System,
88 Video,
90 Io,
92}
93
94#[derive(Debug, Clone, Serialize, PartialEq, Default)]
99#[serde(tag = "type", rename_all = "camelCase")]
100pub enum ParameterType {
101 Number,
103 #[default]
105 String,
106 Boolean,
108 Enum {
111 options: Vec<std::string::String>,
114 },
115 Object,
117 File {
120 accept: Vec<std::string::String>,
122 },
123}
124
125#[derive(Debug, Clone, Serialize, PartialEq)]
130#[serde(rename_all = "camelCase")]
131pub struct Constraints {
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub min: Option<f64>,
135
136 #[serde(skip_serializing_if = "Option::is_none")]
138 pub max: Option<f64>,
139
140 pub required: bool,
142}
143
144#[derive(Debug, Clone, Serialize, PartialEq)]
149#[serde(rename_all = "camelCase")]
150pub struct ParameterDef {
151 pub name: std::string::String,
153
154 pub label: std::string::String,
156
157 pub description: std::string::String,
159
160 pub param_type: ParameterType,
162
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub default: Option<serde_json::Value>,
166
167 #[serde(skip_serializing_if = "Option::is_none")]
169 pub constraints: Option<Constraints>,
170
171 #[serde(skip_serializing_if = "Option::is_none")]
174 pub placeholder: Option<String>,
175
176 #[serde(skip_serializing_if = "Option::is_none")]
178 pub visible_when: Option<ParamCondition>,
179
180 #[serde(skip_serializing_if = "Option::is_none")]
182 pub required_when: Option<ParamCondition>,
183
184 #[serde(default = "default_true")]
187 pub surfaceable: bool,
188}
189
190#[allow(dead_code)]
192fn default_true() -> bool {
193 true
194}
195
196impl Default for ParameterDef {
198 fn default() -> Self {
199 Self {
200 name: String::default(),
201 label: String::default(),
202 description: String::default(),
203 param_type: ParameterType::default(),
204 default: None,
205 constraints: None,
206 placeholder: None,
207 visible_when: None,
208 required_when: None,
209 surfaceable: true,
210 }
211 }
212}
213
214#[derive(Debug, Clone, Serialize, PartialEq)]
225#[serde(rename_all = "camelCase")]
226pub struct NodeTypeInfo {
227 pub name: String,
229 pub label: String,
231 pub description: String,
233 pub category: NodeCategory,
235 pub is_container: bool,
237 pub platforms: Vec<String>,
239 pub icon: String,
241}
242
243macro_rules! node_type {
246 ($name:expr, $label:expr, $desc:expr, $cat:expr, $container:expr, $platform:expr, $icon:expr) => {
247 NodeTypeInfo {
248 name: $name.to_string(),
249 label: $label.to_string(),
250 description: $desc.to_string(),
251 category: $cat,
252 is_container: $container,
253 platforms: vec![$platform.to_string()],
254 icon: $icon.to_string(),
255 }
256 };
257}
258
259pub fn all_node_types() -> Vec<NodeTypeInfo> {
264 let mut types = Vec::with_capacity(20);
265 types.extend(control_node_types());
266 types.extend(data_node_types());
267 types.extend(file_node_types());
268 types.extend(image_node_types());
269 types.extend(io_node_types());
270 types.extend(network_node_types());
271 types.extend(spreadsheet_node_types());
272 types.extend(system_node_types());
273 types.extend(video_node_types());
274 types.sort_by(|a, b| a.name.cmp(&b.name));
275 types
276}
277
278fn control_node_types() -> Vec<NodeTypeInfo> {
279 vec![
280 node_type!(
281 "group",
282 "Group",
283 "Container for child nodes. Orchestrates sequential or parallel execution.",
284 NodeCategory::Control,
285 true,
286 "browser",
287 "box"
288 ),
289 node_type!(
290 "loop",
291 "Loop",
292 "Iterate over arrays (forEach), repeat N times, or loop while condition.",
293 NodeCategory::Control,
294 true,
295 "browser",
296 "repeat"
297 ),
298 node_type!(
299 "parallel",
300 "Parallel",
301 "Execute tasks concurrently with configurable worker pool and error strategy.",
302 NodeCategory::Control,
303 true,
304 "browser",
305 "git-fork"
306 ),
307 ]
308}
309
310fn data_node_types() -> Vec<NodeTypeInfo> {
311 vec![
312 node_type!(
313 "edit-fields",
314 "Edit Fields",
315 "Set field values from static values or template expressions.",
316 NodeCategory::Data,
317 false,
318 "browser",
319 "pen-line"
320 ),
321 node_type!(
322 "transform",
323 "Transform",
324 "Transform data using expressions (single value) or field mappings.",
325 NodeCategory::Data,
326 false,
327 "browser",
328 "arrow-left-right"
329 ),
330 ]
331}
332
333fn file_node_types() -> Vec<NodeTypeInfo> {
334 vec![node_type!(
335 "file-rename",
336 "Rename Files",
337 "Transform filenames using patterns, find/replace, and case rules.",
338 NodeCategory::File,
339 false,
340 "browser",
341 "folder-open"
342 )]
343}
344
345fn image_node_types() -> Vec<NodeTypeInfo> {
346 vec![
347 node_type!(
348 "image-compress",
349 "Compress Images",
350 "Reduce image file size while maintaining quality.",
351 NodeCategory::Image,
352 false,
353 "browser",
354 "image"
355 ),
356 node_type!(
357 "image-convert",
358 "Convert Image Format",
359 "Convert images between JPEG, PNG, and WebP formats.",
360 NodeCategory::Image,
361 false,
362 "browser",
363 "image"
364 ),
365 node_type!(
366 "image-resize",
367 "Resize Images",
368 "Change image dimensions while maintaining quality.",
369 NodeCategory::Image,
370 false,
371 "browser",
372 "image"
373 ),
374 node_type!(
375 "image-strip-exif",
376 "Strip EXIF",
377 "Remove all EXIF metadata from images (GPS, camera info, timestamps).",
378 NodeCategory::Image,
379 false,
380 "browser",
381 "image"
382 ),
383 node_type!(
384 "image-overlay",
385 "Overlay Image",
386 "Overlay an image onto source images at a configurable position, size, and opacity.",
387 NodeCategory::Image,
388 false,
389 "browser",
390 "stamp"
391 ),
392 ]
393}
394
395fn io_node_types() -> Vec<NodeTypeInfo> {
396 vec![
397 node_type!(
398 "input",
399 "Input",
400 "Declares how data enters the recipe.",
401 NodeCategory::Io,
402 false,
403 "browser",
404 "file-up"
405 ),
406 node_type!(
407 "output",
408 "Output",
409 "Declares how results are delivered.",
410 NodeCategory::Io,
411 false,
412 "browser",
413 "download"
414 ),
415 ]
416}
417
418fn network_node_types() -> Vec<NodeTypeInfo> {
419 vec![node_type!(
420 "http-request",
421 "HTTP Request",
422 "Make HTTP requests to APIs (GET, POST, PUT, DELETE, etc.).",
423 NodeCategory::Network,
424 false,
425 "server",
426 "globe"
427 )]
428}
429
430fn spreadsheet_node_types() -> Vec<NodeTypeInfo> {
431 vec![
432 node_type!(
433 "spreadsheet-clean",
434 "Clean CSV",
435 "Remove empty rows, trim whitespace, and deduplicate CSV data.",
436 NodeCategory::Spreadsheet,
437 false,
438 "browser",
439 "sheet"
440 ),
441 node_type!(
442 "spreadsheet-convert",
443 "CSV to JSON",
444 "Convert CSV data to JSON format with configurable delimiters.",
445 NodeCategory::Spreadsheet,
446 false,
447 "browser",
448 "sheet"
449 ),
450 node_type!(
451 "spreadsheet-merge",
452 "Merge CSV",
453 "Combine multiple CSV files into one with header reconciliation and deduplication.",
454 NodeCategory::Spreadsheet,
455 false,
456 "browser",
457 "sheet"
458 ),
459 node_type!(
460 "spreadsheet-rename",
461 "Rename CSV Columns",
462 "Rename column headers in a CSV file.",
463 NodeCategory::Spreadsheet,
464 false,
465 "browser",
466 "sheet"
467 ),
468 ]
469}
470
471fn system_node_types() -> Vec<NodeTypeInfo> {
472 vec![node_type!(
473 "shell-command",
474 "Shell Command",
475 "Execute shell commands with stall detection, retry, and streaming output.",
476 NodeCategory::System,
477 false,
478 "server",
479 "terminal"
480 )]
481}
482
483fn video_node_types() -> Vec<NodeTypeInfo> {
484 vec![node_type!(
485 "video-download",
486 "Download Video",
487 "Download video from URLs using yt-dlp (CLI/desktop only).",
488 NodeCategory::Video,
489 false,
490 "server",
491 "video"
492 )]
493}
494
495#[derive(Debug, Clone, Serialize, PartialEq)]
504#[serde(rename_all = "camelCase")]
505pub struct Dependency {
506 pub binary: String,
508 #[serde(default, skip_serializing_if = "String::is_empty")]
510 pub version: String,
511 pub install_hint: String,
513 #[serde(default, skip_serializing_if = "String::is_empty")]
515 pub homepage: String,
516}
517
518#[derive(Debug, Clone, Serialize, PartialEq)]
524#[serde(rename_all = "camelCase")]
525pub struct NodeMetadata {
526 pub node_type: std::string::String,
528 pub name: std::string::String,
530 pub description: std::string::String,
532 pub category: NodeCategory,
534 pub accepts: Vec<std::string::String>,
536 pub platforms: Vec<std::string::String>,
538 pub parameters: Vec<ParameterDef>,
540 #[serde(default)]
543 pub input_cardinality: InputCardinality,
544 #[serde(default, skip_serializing_if = "Vec::is_empty")]
546 pub requires: Vec<Dependency>,
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 #[test]
556 fn test_input_cardinality_defaults_to_per_file() {
557 let cardinality = InputCardinality::default();
558 assert_eq!(cardinality, InputCardinality::PerFile);
559 }
560
561 #[test]
562 fn test_input_cardinality_serializes_camel_case() {
563 let per_file = serde_json::to_string(&InputCardinality::PerFile).unwrap();
564 assert_eq!(per_file, r#""perFile""#);
565
566 let batch = serde_json::to_string(&InputCardinality::Batch).unwrap();
567 assert_eq!(batch, r#""batch""#);
568
569 let source = serde_json::to_string(&InputCardinality::Source).unwrap();
570 assert_eq!(source, r#""source""#);
571 }
572
573 #[test]
574 fn test_metadata_with_input_cardinality_round_trip() {
575 let metadata = NodeMetadata {
576 node_type: "image-compress".to_string(),
577 name: "Compress Images".to_string(),
578 description: "Reduce image file size".to_string(),
579 category: NodeCategory::Image,
580 accepts: vec!["image/jpeg".to_string()],
581 platforms: vec!["browser".to_string()],
582 parameters: vec![],
583 input_cardinality: InputCardinality::PerFile,
584 requires: vec![],
585 };
586 let json = serde_json::to_string(&metadata).unwrap();
587 assert!(json.contains(r#""inputCardinality":"perFile""#));
588
589 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
590 assert_eq!(parsed["inputCardinality"], "perFile");
591 }
592
593 #[test]
594 fn test_metadata_with_batch_cardinality() {
595 let metadata = NodeMetadata {
596 node_type: "zip-files".to_string(),
597 name: "Zip Files".to_string(),
598 description: "Bundle files into a zip archive".to_string(),
599 category: NodeCategory::File,
600 accepts: vec![],
601 platforms: vec!["browser".to_string()],
602 parameters: vec![],
603 input_cardinality: InputCardinality::Batch,
604 requires: vec![],
605 };
606 let json = serde_json::to_string(&metadata).unwrap();
607 assert!(json.contains(r#""inputCardinality":"batch""#));
608 }
609
610 #[test]
613 fn test_all_node_types_returns_20_entries() {
614 let types = all_node_types();
616 assert_eq!(types.len(), 20, "Should have exactly 20 node types");
617 }
618
619 #[test]
620 fn test_all_node_types_sorted_alphabetically() {
621 let types = all_node_types();
623 let names: Vec<&str> = types.iter().map(|t| t.name.as_str()).collect();
624 let mut sorted = names.clone();
625 sorted.sort();
626 assert_eq!(names, sorted, "Node types should be alphabetically sorted");
627 }
628
629 #[test]
630 fn test_all_node_types_unique_names() {
631 let types = all_node_types();
633 let mut names: Vec<&str> = types.iter().map(|t| t.name.as_str()).collect();
634 names.sort();
635 names.dedup();
636 assert_eq!(names.len(), 20, "All node type names should be unique");
637 }
638
639 #[test]
640 fn test_container_types_are_group_loop_parallel() {
641 let types = all_node_types();
643 let mut containers: Vec<&str> = types
644 .iter()
645 .filter(|t| t.is_container)
646 .map(|t| t.name.as_str())
647 .collect();
648 containers.sort();
649 assert_eq!(containers, vec!["group", "loop", "parallel"]);
650 }
651
652 #[test]
653 fn test_io_types_are_input_output() {
654 let types = all_node_types();
656 let mut io_types: Vec<&str> = types
657 .iter()
658 .filter(|t| t.category == NodeCategory::Io)
659 .map(|t| t.name.as_str())
660 .collect();
661 io_types.sort();
662 assert_eq!(io_types, vec!["input", "output"]);
663 }
664
665 #[test]
666 fn test_server_only_types() {
667 let types = all_node_types();
669 let mut server_only: Vec<&str> = types
670 .iter()
671 .filter(|t| !t.platforms.contains(&"browser".to_string()))
672 .map(|t| t.name.as_str())
673 .collect();
674 server_only.sort();
675 assert_eq!(
676 server_only,
677 vec!["http-request", "shell-command", "video-download"]
678 );
679 }
680
681 #[test]
682 fn test_node_type_info_serializes_camel_case() {
683 let info = NodeTypeInfo {
685 name: "image".to_string(),
686 label: "Image".to_string(),
687 description: "Image processing".to_string(),
688 category: NodeCategory::Image,
689 is_container: false,
690 platforms: vec!["browser".to_string()],
691 icon: "image".to_string(),
692 };
693 let json = serde_json::to_string(&info).unwrap();
694 assert!(json.contains(r#""isContainer":false"#));
696 assert!(!json.contains("is_container"));
697 }
698
699 #[test]
704 fn test_category_serializes_to_kebab_case() {
705 let json = serde_json::to_string(&NodeCategory::Image).unwrap();
707 assert_eq!(json, r#""image""#);
708
709 let json = serde_json::to_string(&NodeCategory::Spreadsheet).unwrap();
710 assert_eq!(json, r#""spreadsheet""#);
711
712 let json = serde_json::to_string(&NodeCategory::File).unwrap();
713 assert_eq!(json, r#""file""#);
714
715 let json = serde_json::to_string(&NodeCategory::Io).unwrap();
716 assert_eq!(json, r#""io""#);
717
718 let json = serde_json::to_string(&NodeCategory::Video).unwrap();
719 assert_eq!(json, r#""video""#);
720 }
721
722 #[test]
723 fn test_parameter_type_number_serialization() {
724 let json = serde_json::to_string(&ParameterType::Number).unwrap();
726 assert_eq!(json, r#"{"type":"number"}"#);
727 }
728
729 #[test]
730 fn test_parameter_type_enum_serialization() {
731 let param = ParameterType::Enum {
733 options: vec!["jpeg".to_string(), "png".to_string(), "webp".to_string()],
734 };
735 let json = serde_json::to_string(¶m).unwrap();
736 assert!(json.contains(r#""type":"enum""#));
737 assert!(json.contains(r#""options":["jpeg","png","webp"]"#));
738 }
739
740 #[test]
741 fn test_constraints_skips_none_fields() {
742 let constraints = Constraints {
744 min: Some(1.0),
745 max: None,
746 required: false,
747 };
748 let json = serde_json::to_string(&constraints).unwrap();
749 assert!(json.contains(r#""min":1.0"#));
751 assert!(!json.contains("max"));
752 assert!(json.contains(r#""required":false"#));
753 }
754
755 #[test]
756 fn test_constraints_includes_all_fields_when_present() {
757 let constraints = Constraints {
758 min: Some(1.0),
759 max: Some(100.0),
760 required: true,
761 };
762 let json = serde_json::to_string(&constraints).unwrap();
763 assert!(json.contains(r#""min":1.0"#));
764 assert!(json.contains(r#""max":100.0"#));
765 assert!(json.contains(r#""required":true"#));
766 }
767
768 #[test]
769 fn test_parameter_def_serializes_camel_case() {
770 let param = ParameterDef {
772 name: "quality".to_string(),
773 label: "Quality".to_string(),
774 description: "Compression quality".to_string(),
775 param_type: ParameterType::Number,
776 default: Some(serde_json::json!(80)),
777 constraints: Some(Constraints {
778 min: Some(1.0),
779 max: Some(100.0),
780 required: false,
781 }),
782 ..Default::default()
783 };
784 let json = serde_json::to_string(¶m).unwrap();
785 assert!(json.contains(r#""paramType""#));
787 assert!(!json.contains("param_type"));
788 }
789
790 #[test]
791 fn test_parameter_def_skips_none_default() {
792 let param = ParameterDef {
794 name: "width".to_string(),
795 label: "Width".to_string(),
796 description: "Target width".to_string(),
797 param_type: ParameterType::Number,
798 ..Default::default()
799 };
800 let json = serde_json::to_string(¶m).unwrap();
801 assert!(!json.contains("default"));
802 assert!(!json.contains("constraints"));
803 assert!(!json.contains("placeholder"));
805 assert!(!json.contains("visibleWhen"));
806 assert!(!json.contains("requiredWhen"));
807 }
808
809 #[test]
810 fn test_parameter_def_surfaceable_defaults_to_true() {
811 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 ..Default::default()
819 };
820 assert!(param.surfaceable, "surfaceable should default to true");
822 let json = serde_json::to_string(¶m).unwrap();
824 assert!(json.contains(r#""surfaceable":true"#));
825 }
826
827 #[test]
828 fn test_parameter_def_surfaceable_false_serializes() {
829 let param = ParameterDef {
832 name: "items".to_string(),
833 label: "Items".to_string(),
834 description: "Template expression for iteration items".to_string(),
835 param_type: ParameterType::String,
836 surfaceable: false,
837 ..Default::default()
838 };
839 assert!(!param.surfaceable);
840 let json = serde_json::to_string(¶m).unwrap();
841 assert!(json.contains(r#""surfaceable":false"#));
842 }
843
844 #[test]
845 fn test_node_metadata_serializes_camel_case() {
846 let metadata = NodeMetadata {
848 node_type: "image-compress".to_string(),
849 name: "Compress Images".to_string(),
850 description: "Reduce image file size".to_string(),
851 category: NodeCategory::Image,
852 accepts: vec![
853 "image/jpeg".to_string(),
854 "image/png".to_string(),
855 "image/webp".to_string(),
856 ],
857 platforms: vec!["browser".to_string()],
858 parameters: vec![],
859 input_cardinality: InputCardinality::PerFile,
860 requires: vec![],
861 };
862 let json = serde_json::to_string(&metadata).unwrap();
863 assert!(json.contains(r#""nodeType":"image-compress""#));
865 assert!(json.contains(r#""platforms":["browser"]"#));
866 assert!(!json.contains("node_type"));
867 }
868
869 #[test]
870 fn test_full_metadata_round_trip() {
871 let metadata = NodeMetadata {
874 node_type: "image-compress".to_string(),
875 name: "Compress Images".to_string(),
876 description: "Reduce image file size while maintaining quality".to_string(),
877 category: NodeCategory::Image,
878 accepts: vec![
879 "image/jpeg".to_string(),
880 "image/png".to_string(),
881 "image/webp".to_string(),
882 ],
883 platforms: vec!["browser".to_string()],
884 parameters: vec![ParameterDef {
885 name: "quality".to_string(),
886 label: "Quality".to_string(),
887 description: "Compression quality (1-100)".to_string(),
888 param_type: ParameterType::Number,
889 default: Some(serde_json::json!(80)),
890 constraints: Some(Constraints {
891 min: Some(1.0),
892 max: Some(100.0),
893 required: false,
894 }),
895 ..Default::default()
896 }],
897 input_cardinality: InputCardinality::PerFile,
898 requires: vec![],
899 };
900
901 let json = serde_json::to_string_pretty(&metadata).unwrap();
903
904 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
906
907 assert_eq!(parsed["nodeType"], "image-compress");
909 assert_eq!(parsed["category"], "image");
910 assert_eq!(parsed["platforms"][0], "browser");
911 assert_eq!(parsed["accepts"].as_array().unwrap().len(), 3);
912 assert_eq!(parsed["parameters"].as_array().unwrap().len(), 1);
913 assert_eq!(parsed["parameters"][0]["name"], "quality");
914 assert_eq!(parsed["parameters"][0]["default"], 80);
915 }
916
917 #[test]
920 fn test_param_condition_single_serializes_as_object() {
921 let condition = ParamCondition::Single(ParamConditionEntry {
924 param: "operation".to_string(),
925 equals: "resize".to_string(),
926 });
927 let json = serde_json::to_string(&condition).unwrap();
928 assert_eq!(json, r#"{"param":"operation","equals":"resize"}"#);
930 }
931
932 #[test]
933 fn test_param_condition_any_serializes_as_array() {
934 let condition = ParamCondition::Any(vec![
937 ParamConditionEntry {
938 param: "operation".to_string(),
939 equals: "resize".to_string(),
940 },
941 ParamConditionEntry {
942 param: "operation".to_string(),
943 equals: "crop".to_string(),
944 },
945 ]);
946 let json = serde_json::to_string(&condition).unwrap();
947 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
949 assert!(parsed.is_array(), "Any condition should be a JSON array");
950 assert_eq!(parsed.as_array().unwrap().len(), 2);
951 assert_eq!(parsed[0]["param"], "operation");
952 assert_eq!(parsed[0]["equals"], "resize");
953 assert_eq!(parsed[1]["equals"], "crop");
954 }
955
956 #[test]
957 fn test_parameter_def_with_ui_fields_serializes_camel_case() {
958 let param = ParameterDef {
961 name: "width".to_string(),
962 label: "Width".to_string(),
963 description: "Target width in pixels".to_string(),
964 param_type: ParameterType::Number,
965 default: None,
966 constraints: None,
967 placeholder: Some("e.g. 800".to_string()),
968 visible_when: Some(ParamCondition::Single(ParamConditionEntry {
969 param: "operation".to_string(),
970 equals: "resize".to_string(),
971 })),
972 ..Default::default()
973 };
974 let json = serde_json::to_string(¶m).unwrap();
975 assert!(json.contains(r#""visibleWhen""#));
977 assert!(!json.contains("visible_when"));
978 assert!(json.contains(r#""placeholder":"e.g. 800""#));
980 assert!(!json.contains("requiredWhen"));
982 }
983
984 #[test]
987 fn test_dependency_serializes_camel_case() {
988 let dep = Dependency {
989 binary: "yt-dlp".to_string(),
990 version: ">=2023.01.01".to_string(),
991 install_hint: "brew install yt-dlp".to_string(),
992 homepage: "https://github.com/yt-dlp/yt-dlp".to_string(),
993 };
994 let json = serde_json::to_string(&dep).unwrap();
995 assert!(json.contains(r#""binary":"yt-dlp""#));
996 assert!(json.contains(r#""version":">=2023.01.01""#));
997 assert!(json.contains(r#""installHint":"brew install yt-dlp""#));
998 assert!(json.contains(r#""homepage":"https://github.com/yt-dlp/yt-dlp""#));
999 assert!(!json.contains("install_hint"));
1001 }
1002
1003 #[test]
1004 fn test_dependency_skips_empty_optional_fields() {
1005 let dep = Dependency {
1006 binary: "ffmpeg".to_string(),
1007 version: String::new(),
1008 install_hint: "brew install ffmpeg".to_string(),
1009 homepage: String::new(),
1010 };
1011 let json = serde_json::to_string(&dep).unwrap();
1012 assert!(!json.contains("version"), "empty version should be omitted");
1013 assert!(
1014 !json.contains("homepage"),
1015 "empty homepage should be omitted"
1016 );
1017 assert!(json.contains(r#""binary":"ffmpeg""#));
1018 assert!(json.contains(r#""installHint""#));
1019 }
1020
1021 #[test]
1022 fn test_dependency_equality() {
1023 let a = Dependency {
1024 binary: "yt-dlp".to_string(),
1025 version: ">=2023.01.01".to_string(),
1026 install_hint: "brew install yt-dlp".to_string(),
1027 homepage: "https://github.com/yt-dlp/yt-dlp".to_string(),
1028 };
1029 let b = a.clone();
1030 assert_eq!(a, b);
1031 }
1032
1033 #[test]
1034 fn test_metadata_requires_empty_skipped_in_serialization() {
1035 let metadata = NodeMetadata {
1036 node_type: "image-compress".to_string(),
1037 name: "Compress".to_string(),
1038 description: String::new(),
1039 category: NodeCategory::Image,
1040 accepts: vec![],
1041 platforms: vec!["browser".to_string()],
1042 parameters: vec![],
1043 input_cardinality: InputCardinality::PerFile,
1044 requires: vec![],
1045 };
1046 let json = serde_json::to_string(&metadata).unwrap();
1047 assert!(
1048 !json.contains("requires"),
1049 "empty requires should be omitted"
1050 );
1051 }
1052
1053 #[test]
1054 fn test_metadata_requires_present_when_populated() {
1055 let metadata = NodeMetadata {
1056 node_type: "video-download".to_string(),
1057 name: "Download Video".to_string(),
1058 description: String::new(),
1059 category: NodeCategory::Network,
1060 accepts: vec![],
1061 platforms: vec!["server".to_string()],
1062 parameters: vec![],
1063 input_cardinality: InputCardinality::PerFile,
1064 requires: vec![Dependency {
1065 binary: "yt-dlp".to_string(),
1066 version: ">=2023.01.01".to_string(),
1067 install_hint: "brew install yt-dlp".to_string(),
1068 homepage: "https://github.com/yt-dlp/yt-dlp".to_string(),
1069 }],
1070 };
1071 let json = serde_json::to_string(&metadata).unwrap();
1072 assert!(json.contains(r#""requires""#));
1073 assert!(json.contains(r#""binary":"yt-dlp""#));
1074 assert!(json.contains(r#""version":">=2023.01.01""#));
1075 }
1076}