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}
63
64#[derive(Debug, Clone, Serialize, PartialEq)]
69#[serde(rename_all = "kebab-case")]
70pub enum NodeCategory {
71 Image,
73 Spreadsheet,
75 File,
77 Data,
79 Network,
81 Control,
83 System,
85 Io,
87}
88
89#[derive(Debug, Clone, Serialize, PartialEq, Default)]
94#[serde(tag = "type", rename_all = "camelCase")]
95pub enum ParameterType {
96 Number,
98 #[default]
100 String,
101 Boolean,
103 Enum {
106 options: Vec<std::string::String>,
109 },
110 Object,
112 File {
115 accept: Vec<std::string::String>,
117 },
118}
119
120#[derive(Debug, Clone, Serialize, PartialEq)]
125#[serde(rename_all = "camelCase")]
126pub struct Constraints {
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub min: Option<f64>,
130
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub max: Option<f64>,
134
135 pub required: bool,
137}
138
139#[derive(Debug, Clone, Serialize, PartialEq)]
144#[serde(rename_all = "camelCase")]
145pub struct ParameterDef {
146 pub name: std::string::String,
148
149 pub label: std::string::String,
151
152 pub description: std::string::String,
154
155 pub param_type: ParameterType,
157
158 #[serde(skip_serializing_if = "Option::is_none")]
160 pub default: Option<serde_json::Value>,
161
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub constraints: Option<Constraints>,
165
166 #[serde(skip_serializing_if = "Option::is_none")]
169 pub placeholder: Option<String>,
170
171 #[serde(skip_serializing_if = "Option::is_none")]
173 pub visible_when: Option<ParamCondition>,
174
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub required_when: Option<ParamCondition>,
178
179 #[serde(default = "default_true")]
182 pub surfaceable: bool,
183}
184
185#[allow(dead_code)]
187fn default_true() -> bool {
188 true
189}
190
191impl Default for ParameterDef {
193 fn default() -> Self {
194 Self {
195 name: String::default(),
196 label: String::default(),
197 description: String::default(),
198 param_type: ParameterType::default(),
199 default: None,
200 constraints: None,
201 placeholder: None,
202 visible_when: None,
203 required_when: None,
204 surfaceable: true,
205 }
206 }
207}
208
209#[derive(Debug, Clone, Serialize, PartialEq)]
220#[serde(rename_all = "camelCase")]
221pub struct NodeTypeInfo {
222 pub name: String,
224 pub label: String,
226 pub description: String,
228 pub category: NodeCategory,
230 pub is_container: bool,
232 pub platforms: Vec<String>,
234 pub icon: String,
236}
237
238macro_rules! node_type {
241 ($name:expr, $label:expr, $desc:expr, $cat:expr, $container:expr, $platform:expr, $icon:expr) => {
242 NodeTypeInfo {
243 name: $name.to_string(),
244 label: $label.to_string(),
245 description: $desc.to_string(),
246 category: $cat,
247 is_container: $container,
248 platforms: vec![$platform.to_string()],
249 icon: $icon.to_string(),
250 }
251 };
252}
253
254pub fn all_node_types() -> Vec<NodeTypeInfo> {
259 let mut types = Vec::with_capacity(19);
260 types.extend(control_node_types());
261 types.extend(data_node_types());
262 types.extend(file_node_types());
263 types.extend(image_node_types());
264 types.extend(io_node_types());
265 types.extend(network_node_types());
266 types.extend(spreadsheet_node_types());
267 types.extend(system_node_types());
268 types.sort_by(|a, b| a.name.cmp(&b.name));
269 types
270}
271
272fn control_node_types() -> Vec<NodeTypeInfo> {
273 vec![
274 node_type!(
275 "group",
276 "Group",
277 "Container for child nodes. Orchestrates sequential or parallel execution.",
278 NodeCategory::Control,
279 true,
280 "browser",
281 "box"
282 ),
283 node_type!(
284 "loop",
285 "Loop",
286 "Iterate over arrays (forEach), repeat N times, or loop while condition.",
287 NodeCategory::Control,
288 true,
289 "browser",
290 "repeat"
291 ),
292 node_type!(
293 "parallel",
294 "Parallel",
295 "Execute tasks concurrently with configurable worker pool and error strategy.",
296 NodeCategory::Control,
297 true,
298 "browser",
299 "git-fork"
300 ),
301 ]
302}
303
304fn data_node_types() -> Vec<NodeTypeInfo> {
305 vec![
306 node_type!(
307 "edit-fields",
308 "Edit Fields",
309 "Set field values from static values or template expressions.",
310 NodeCategory::Data,
311 false,
312 "browser",
313 "pen-line"
314 ),
315 node_type!(
316 "transform",
317 "Transform",
318 "Transform data using expressions (single value) or field mappings.",
319 NodeCategory::Data,
320 false,
321 "browser",
322 "arrow-left-right"
323 ),
324 ]
325}
326
327fn file_node_types() -> Vec<NodeTypeInfo> {
328 vec![node_type!(
329 "file-rename",
330 "Rename Files",
331 "Transform filenames using patterns, find/replace, and case rules.",
332 NodeCategory::File,
333 false,
334 "browser",
335 "folder-open"
336 )]
337}
338
339fn image_node_types() -> Vec<NodeTypeInfo> {
340 vec![
341 node_type!(
342 "image-compress",
343 "Compress Images",
344 "Reduce image file size while maintaining quality.",
345 NodeCategory::Image,
346 false,
347 "browser",
348 "image"
349 ),
350 node_type!(
351 "image-convert",
352 "Convert Image Format",
353 "Convert images between JPEG, PNG, and WebP formats.",
354 NodeCategory::Image,
355 false,
356 "browser",
357 "image"
358 ),
359 node_type!(
360 "image-resize",
361 "Resize Images",
362 "Change image dimensions while maintaining quality.",
363 NodeCategory::Image,
364 false,
365 "browser",
366 "image"
367 ),
368 node_type!(
369 "image-strip-exif",
370 "Strip EXIF",
371 "Remove all EXIF metadata from images (GPS, camera info, timestamps).",
372 NodeCategory::Image,
373 false,
374 "browser",
375 "image"
376 ),
377 node_type!(
378 "image-overlay",
379 "Overlay Image",
380 "Overlay an image onto source images at a configurable position, size, and opacity.",
381 NodeCategory::Image,
382 false,
383 "browser",
384 "stamp"
385 ),
386 ]
387}
388
389fn io_node_types() -> Vec<NodeTypeInfo> {
390 vec![
391 node_type!(
392 "input",
393 "Input",
394 "Declares how data enters the recipe.",
395 NodeCategory::Io,
396 false,
397 "browser",
398 "file-up"
399 ),
400 node_type!(
401 "output",
402 "Output",
403 "Declares how results are delivered.",
404 NodeCategory::Io,
405 false,
406 "browser",
407 "download"
408 ),
409 ]
410}
411
412fn network_node_types() -> Vec<NodeTypeInfo> {
413 vec![node_type!(
414 "http-request",
415 "HTTP Request",
416 "Make HTTP requests to APIs (GET, POST, PUT, DELETE, etc.).",
417 NodeCategory::Network,
418 false,
419 "server",
420 "globe"
421 )]
422}
423
424fn spreadsheet_node_types() -> Vec<NodeTypeInfo> {
425 vec![
426 node_type!(
427 "spreadsheet-clean",
428 "Clean CSV",
429 "Remove empty rows, trim whitespace, and deduplicate CSV data.",
430 NodeCategory::Spreadsheet,
431 false,
432 "browser",
433 "sheet"
434 ),
435 node_type!(
436 "spreadsheet-convert",
437 "CSV to JSON",
438 "Convert CSV data to JSON format with configurable delimiters.",
439 NodeCategory::Spreadsheet,
440 false,
441 "browser",
442 "sheet"
443 ),
444 node_type!(
445 "spreadsheet-merge",
446 "Merge CSV",
447 "Combine multiple CSV files into one with header reconciliation and deduplication.",
448 NodeCategory::Spreadsheet,
449 false,
450 "browser",
451 "sheet"
452 ),
453 node_type!(
454 "spreadsheet-rename",
455 "Rename CSV Columns",
456 "Rename column headers in a CSV file.",
457 NodeCategory::Spreadsheet,
458 false,
459 "browser",
460 "sheet"
461 ),
462 ]
463}
464
465fn system_node_types() -> Vec<NodeTypeInfo> {
466 vec![node_type!(
467 "shell-command",
468 "Shell Command",
469 "Execute shell commands with stall detection, retry, and streaming output.",
470 NodeCategory::System,
471 false,
472 "server",
473 "terminal"
474 )]
475}
476
477#[derive(Debug, Clone, Serialize, PartialEq)]
483#[serde(rename_all = "camelCase")]
484pub struct NodeMetadata {
485 pub node_type: std::string::String,
487 pub name: std::string::String,
489 pub description: std::string::String,
491 pub category: NodeCategory,
493 pub accepts: Vec<std::string::String>,
495 pub platforms: Vec<std::string::String>,
497 pub parameters: Vec<ParameterDef>,
499 #[serde(default)]
502 pub input_cardinality: InputCardinality,
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508
509 #[test]
512 fn test_input_cardinality_defaults_to_per_file() {
513 let cardinality = InputCardinality::default();
514 assert_eq!(cardinality, InputCardinality::PerFile);
515 }
516
517 #[test]
518 fn test_input_cardinality_serializes_camel_case() {
519 let per_file = serde_json::to_string(&InputCardinality::PerFile).unwrap();
520 assert_eq!(per_file, r#""perFile""#);
521
522 let batch = serde_json::to_string(&InputCardinality::Batch).unwrap();
523 assert_eq!(batch, r#""batch""#);
524 }
525
526 #[test]
527 fn test_metadata_with_input_cardinality_round_trip() {
528 let metadata = NodeMetadata {
529 node_type: "image-compress".to_string(),
530 name: "Compress Images".to_string(),
531 description: "Reduce image file size".to_string(),
532 category: NodeCategory::Image,
533 accepts: vec!["image/jpeg".to_string()],
534 platforms: vec!["browser".to_string()],
535 parameters: vec![],
536 input_cardinality: InputCardinality::PerFile,
537 };
538 let json = serde_json::to_string(&metadata).unwrap();
539 assert!(json.contains(r#""inputCardinality":"perFile""#));
540
541 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
542 assert_eq!(parsed["inputCardinality"], "perFile");
543 }
544
545 #[test]
546 fn test_metadata_with_batch_cardinality() {
547 let metadata = NodeMetadata {
548 node_type: "zip-files".to_string(),
549 name: "Zip Files".to_string(),
550 description: "Bundle files into a zip archive".to_string(),
551 category: NodeCategory::File,
552 accepts: vec![],
553 platforms: vec!["browser".to_string()],
554 parameters: vec![],
555 input_cardinality: InputCardinality::Batch,
556 };
557 let json = serde_json::to_string(&metadata).unwrap();
558 assert!(json.contains(r#""inputCardinality":"batch""#));
559 }
560
561 #[test]
564 fn test_all_node_types_returns_19_entries() {
565 let types = all_node_types();
567 assert_eq!(types.len(), 19, "Should have exactly 19 node types");
568 }
569
570 #[test]
571 fn test_all_node_types_sorted_alphabetically() {
572 let types = all_node_types();
574 let names: Vec<&str> = types.iter().map(|t| t.name.as_str()).collect();
575 let mut sorted = names.clone();
576 sorted.sort();
577 assert_eq!(names, sorted, "Node types should be alphabetically sorted");
578 }
579
580 #[test]
581 fn test_all_node_types_unique_names() {
582 let types = all_node_types();
584 let mut names: Vec<&str> = types.iter().map(|t| t.name.as_str()).collect();
585 names.sort();
586 names.dedup();
587 assert_eq!(names.len(), 19, "All node type names should be unique");
588 }
589
590 #[test]
591 fn test_container_types_are_group_loop_parallel() {
592 let types = all_node_types();
594 let mut containers: Vec<&str> = types
595 .iter()
596 .filter(|t| t.is_container)
597 .map(|t| t.name.as_str())
598 .collect();
599 containers.sort();
600 assert_eq!(containers, vec!["group", "loop", "parallel"]);
601 }
602
603 #[test]
604 fn test_io_types_are_input_output() {
605 let types = all_node_types();
607 let mut io_types: Vec<&str> = types
608 .iter()
609 .filter(|t| t.category == NodeCategory::Io)
610 .map(|t| t.name.as_str())
611 .collect();
612 io_types.sort();
613 assert_eq!(io_types, vec!["input", "output"]);
614 }
615
616 #[test]
617 fn test_server_only_types() {
618 let types = all_node_types();
620 let mut server_only: Vec<&str> = types
621 .iter()
622 .filter(|t| !t.platforms.contains(&"browser".to_string()))
623 .map(|t| t.name.as_str())
624 .collect();
625 server_only.sort();
626 assert_eq!(server_only, vec!["http-request", "shell-command"]);
627 }
628
629 #[test]
630 fn test_node_type_info_serializes_camel_case() {
631 let info = NodeTypeInfo {
633 name: "image".to_string(),
634 label: "Image".to_string(),
635 description: "Image processing".to_string(),
636 category: NodeCategory::Image,
637 is_container: false,
638 platforms: vec!["browser".to_string()],
639 icon: "image".to_string(),
640 };
641 let json = serde_json::to_string(&info).unwrap();
642 assert!(json.contains(r#""isContainer":false"#));
644 assert!(!json.contains("is_container"));
645 }
646
647 #[test]
652 fn test_category_serializes_to_kebab_case() {
653 let json = serde_json::to_string(&NodeCategory::Image).unwrap();
655 assert_eq!(json, r#""image""#);
656
657 let json = serde_json::to_string(&NodeCategory::Spreadsheet).unwrap();
658 assert_eq!(json, r#""spreadsheet""#);
659
660 let json = serde_json::to_string(&NodeCategory::File).unwrap();
661 assert_eq!(json, r#""file""#);
662
663 let json = serde_json::to_string(&NodeCategory::Io).unwrap();
664 assert_eq!(json, r#""io""#);
665 }
666
667 #[test]
668 fn test_parameter_type_number_serialization() {
669 let json = serde_json::to_string(&ParameterType::Number).unwrap();
671 assert_eq!(json, r#"{"type":"number"}"#);
672 }
673
674 #[test]
675 fn test_parameter_type_enum_serialization() {
676 let param = ParameterType::Enum {
678 options: vec!["jpeg".to_string(), "png".to_string(), "webp".to_string()],
679 };
680 let json = serde_json::to_string(¶m).unwrap();
681 assert!(json.contains(r#""type":"enum""#));
682 assert!(json.contains(r#""options":["jpeg","png","webp"]"#));
683 }
684
685 #[test]
686 fn test_constraints_skips_none_fields() {
687 let constraints = Constraints {
689 min: Some(1.0),
690 max: None,
691 required: false,
692 };
693 let json = serde_json::to_string(&constraints).unwrap();
694 assert!(json.contains(r#""min":1.0"#));
696 assert!(!json.contains("max"));
697 assert!(json.contains(r#""required":false"#));
698 }
699
700 #[test]
701 fn test_constraints_includes_all_fields_when_present() {
702 let constraints = Constraints {
703 min: Some(1.0),
704 max: Some(100.0),
705 required: true,
706 };
707 let json = serde_json::to_string(&constraints).unwrap();
708 assert!(json.contains(r#""min":1.0"#));
709 assert!(json.contains(r#""max":100.0"#));
710 assert!(json.contains(r#""required":true"#));
711 }
712
713 #[test]
714 fn test_parameter_def_serializes_camel_case() {
715 let param = ParameterDef {
717 name: "quality".to_string(),
718 label: "Quality".to_string(),
719 description: "Compression quality".to_string(),
720 param_type: ParameterType::Number,
721 default: Some(serde_json::json!(80)),
722 constraints: Some(Constraints {
723 min: Some(1.0),
724 max: Some(100.0),
725 required: false,
726 }),
727 ..Default::default()
728 };
729 let json = serde_json::to_string(¶m).unwrap();
730 assert!(json.contains(r#""paramType""#));
732 assert!(!json.contains("param_type"));
733 }
734
735 #[test]
736 fn test_parameter_def_skips_none_default() {
737 let param = ParameterDef {
739 name: "width".to_string(),
740 label: "Width".to_string(),
741 description: "Target width".to_string(),
742 param_type: ParameterType::Number,
743 ..Default::default()
744 };
745 let json = serde_json::to_string(¶m).unwrap();
746 assert!(!json.contains("default"));
747 assert!(!json.contains("constraints"));
748 assert!(!json.contains("placeholder"));
750 assert!(!json.contains("visibleWhen"));
751 assert!(!json.contains("requiredWhen"));
752 }
753
754 #[test]
755 fn test_parameter_def_surfaceable_defaults_to_true() {
756 let param = ParameterDef {
759 name: "quality".to_string(),
760 label: "Quality".to_string(),
761 description: "Compression quality".to_string(),
762 param_type: ParameterType::Number,
763 ..Default::default()
764 };
765 assert!(param.surfaceable, "surfaceable should default to true");
767 let json = serde_json::to_string(¶m).unwrap();
769 assert!(json.contains(r#""surfaceable":true"#));
770 }
771
772 #[test]
773 fn test_parameter_def_surfaceable_false_serializes() {
774 let param = ParameterDef {
777 name: "items".to_string(),
778 label: "Items".to_string(),
779 description: "Template expression for iteration items".to_string(),
780 param_type: ParameterType::String,
781 surfaceable: false,
782 ..Default::default()
783 };
784 assert!(!param.surfaceable);
785 let json = serde_json::to_string(¶m).unwrap();
786 assert!(json.contains(r#""surfaceable":false"#));
787 }
788
789 #[test]
790 fn test_node_metadata_serializes_camel_case() {
791 let metadata = NodeMetadata {
793 node_type: "image-compress".to_string(),
794 name: "Compress Images".to_string(),
795 description: "Reduce image file size".to_string(),
796 category: NodeCategory::Image,
797 accepts: vec![
798 "image/jpeg".to_string(),
799 "image/png".to_string(),
800 "image/webp".to_string(),
801 ],
802 platforms: vec!["browser".to_string()],
803 parameters: vec![],
804 input_cardinality: InputCardinality::PerFile,
805 };
806 let json = serde_json::to_string(&metadata).unwrap();
807 assert!(json.contains(r#""nodeType":"image-compress""#));
809 assert!(json.contains(r#""platforms":["browser"]"#));
810 assert!(!json.contains("node_type"));
811 }
812
813 #[test]
814 fn test_full_metadata_round_trip() {
815 let metadata = NodeMetadata {
818 node_type: "image-compress".to_string(),
819 name: "Compress Images".to_string(),
820 description: "Reduce image file size while maintaining quality".to_string(),
821 category: NodeCategory::Image,
822 accepts: vec![
823 "image/jpeg".to_string(),
824 "image/png".to_string(),
825 "image/webp".to_string(),
826 ],
827 platforms: vec!["browser".to_string()],
828 parameters: vec![ParameterDef {
829 name: "quality".to_string(),
830 label: "Quality".to_string(),
831 description: "Compression quality (1-100)".to_string(),
832 param_type: ParameterType::Number,
833 default: Some(serde_json::json!(80)),
834 constraints: Some(Constraints {
835 min: Some(1.0),
836 max: Some(100.0),
837 required: false,
838 }),
839 ..Default::default()
840 }],
841 input_cardinality: InputCardinality::PerFile,
842 };
843
844 let json = serde_json::to_string_pretty(&metadata).unwrap();
846
847 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
849
850 assert_eq!(parsed["nodeType"], "image-compress");
852 assert_eq!(parsed["category"], "image");
853 assert_eq!(parsed["platforms"][0], "browser");
854 assert_eq!(parsed["accepts"].as_array().unwrap().len(), 3);
855 assert_eq!(parsed["parameters"].as_array().unwrap().len(), 1);
856 assert_eq!(parsed["parameters"][0]["name"], "quality");
857 assert_eq!(parsed["parameters"][0]["default"], 80);
858 }
859
860 #[test]
863 fn test_param_condition_single_serializes_as_object() {
864 let condition = ParamCondition::Single(ParamConditionEntry {
867 param: "operation".to_string(),
868 equals: "resize".to_string(),
869 });
870 let json = serde_json::to_string(&condition).unwrap();
871 assert_eq!(json, r#"{"param":"operation","equals":"resize"}"#);
873 }
874
875 #[test]
876 fn test_param_condition_any_serializes_as_array() {
877 let condition = ParamCondition::Any(vec![
880 ParamConditionEntry {
881 param: "operation".to_string(),
882 equals: "resize".to_string(),
883 },
884 ParamConditionEntry {
885 param: "operation".to_string(),
886 equals: "crop".to_string(),
887 },
888 ]);
889 let json = serde_json::to_string(&condition).unwrap();
890 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
892 assert!(parsed.is_array(), "Any condition should be a JSON array");
893 assert_eq!(parsed.as_array().unwrap().len(), 2);
894 assert_eq!(parsed[0]["param"], "operation");
895 assert_eq!(parsed[0]["equals"], "resize");
896 assert_eq!(parsed[1]["equals"], "crop");
897 }
898
899 #[test]
900 fn test_parameter_def_with_ui_fields_serializes_camel_case() {
901 let param = ParameterDef {
904 name: "width".to_string(),
905 label: "Width".to_string(),
906 description: "Target width in pixels".to_string(),
907 param_type: ParameterType::Number,
908 default: None,
909 constraints: None,
910 placeholder: Some("e.g. 800".to_string()),
911 visible_when: Some(ParamCondition::Single(ParamConditionEntry {
912 param: "operation".to_string(),
913 equals: "resize".to_string(),
914 })),
915 ..Default::default()
916 };
917 let json = serde_json::to_string(¶m).unwrap();
918 assert!(json.contains(r#""visibleWhen""#));
920 assert!(!json.contains("visible_when"));
921 assert!(json.contains(r#""placeholder":"e.g. 800""#));
923 assert!(!json.contains("requiredWhen"));
925 }
926}