use serde::Serialize;
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ParamConditionEntry {
pub param: String,
pub equals: String,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(untagged)]
pub enum ParamCondition {
Single(ParamConditionEntry),
Any(Vec<ParamConditionEntry>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum InputCardinality {
#[default]
PerFile,
Batch,
Source,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum NodeCategory {
Image,
Spreadsheet,
File,
Data,
Network,
Control,
System,
Video,
Io,
}
#[derive(Debug, Clone, Serialize, PartialEq, Default)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ParameterType {
Number,
#[default]
String,
Boolean,
Enum {
options: Vec<std::string::String>,
},
Object,
File {
accept: Vec<std::string::String>,
},
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Constraints {
#[serde(skip_serializing_if = "Option::is_none")]
pub min: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max: Option<f64>,
pub required: bool,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ParameterDef {
pub name: std::string::String,
pub label: std::string::String,
pub description: std::string::String,
pub param_type: ParameterType,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub constraints: Option<Constraints>,
#[serde(skip_serializing_if = "Option::is_none")]
pub placeholder: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visible_when: Option<ParamCondition>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required_when: Option<ParamCondition>,
#[serde(default = "default_true")]
pub surfaceable: bool,
}
#[allow(dead_code)]
fn default_true() -> bool {
true
}
impl Default for ParameterDef {
fn default() -> Self {
Self {
name: String::default(),
label: String::default(),
description: String::default(),
param_type: ParameterType::default(),
default: None,
constraints: None,
placeholder: None,
visible_when: None,
required_when: None,
surfaceable: true,
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct NodeTypeInfo {
pub name: String,
pub label: String,
pub description: String,
pub category: NodeCategory,
pub is_container: bool,
pub platforms: Vec<String>,
pub icon: String,
}
macro_rules! node_type {
($name:expr, $label:expr, $desc:expr, $cat:expr, $container:expr, $platform:expr, $icon:expr) => {
NodeTypeInfo {
name: $name.to_string(),
label: $label.to_string(),
description: $desc.to_string(),
category: $cat,
is_container: $container,
platforms: vec![$platform.to_string()],
icon: $icon.to_string(),
}
};
}
pub fn all_node_types() -> Vec<NodeTypeInfo> {
let mut types = Vec::with_capacity(20);
types.extend(control_node_types());
types.extend(data_node_types());
types.extend(file_node_types());
types.extend(image_node_types());
types.extend(io_node_types());
types.extend(network_node_types());
types.extend(spreadsheet_node_types());
types.extend(system_node_types());
types.extend(video_node_types());
types.sort_by(|a, b| a.name.cmp(&b.name));
types
}
fn control_node_types() -> Vec<NodeTypeInfo> {
vec![
node_type!(
"group",
"Group",
"Container for child nodes. Orchestrates sequential or parallel execution.",
NodeCategory::Control,
true,
"browser",
"box"
),
node_type!(
"loop",
"Loop",
"Iterate over arrays (forEach), repeat N times, or loop while condition.",
NodeCategory::Control,
true,
"browser",
"repeat"
),
node_type!(
"parallel",
"Parallel",
"Execute tasks concurrently with configurable worker pool and error strategy.",
NodeCategory::Control,
true,
"browser",
"git-fork"
),
]
}
fn data_node_types() -> Vec<NodeTypeInfo> {
vec![
node_type!(
"edit-fields",
"Edit Fields",
"Set field values from static values or template expressions.",
NodeCategory::Data,
false,
"browser",
"pen-line"
),
node_type!(
"transform",
"Transform",
"Transform data using expressions (single value) or field mappings.",
NodeCategory::Data,
false,
"browser",
"arrow-left-right"
),
]
}
fn file_node_types() -> Vec<NodeTypeInfo> {
vec![node_type!(
"file-rename",
"Rename Files",
"Transform filenames using patterns, find/replace, and case rules.",
NodeCategory::File,
false,
"browser",
"folder-open"
)]
}
fn image_node_types() -> Vec<NodeTypeInfo> {
vec![
node_type!(
"image-compress",
"Compress Images",
"Reduce image file size while maintaining quality.",
NodeCategory::Image,
false,
"browser",
"image"
),
node_type!(
"image-convert",
"Convert Image Format",
"Convert images between JPEG, PNG, and WebP formats.",
NodeCategory::Image,
false,
"browser",
"image"
),
node_type!(
"image-resize",
"Resize Images",
"Change image dimensions while maintaining quality.",
NodeCategory::Image,
false,
"browser",
"image"
),
node_type!(
"image-strip-exif",
"Strip EXIF",
"Remove all EXIF metadata from images (GPS, camera info, timestamps).",
NodeCategory::Image,
false,
"browser",
"image"
),
node_type!(
"image-overlay",
"Overlay Image",
"Overlay an image onto source images at a configurable position, size, and opacity.",
NodeCategory::Image,
false,
"browser",
"stamp"
),
]
}
fn io_node_types() -> Vec<NodeTypeInfo> {
vec![
node_type!(
"input",
"Input",
"Declares how data enters the recipe.",
NodeCategory::Io,
false,
"browser",
"file-up"
),
node_type!(
"output",
"Output",
"Declares how results are delivered.",
NodeCategory::Io,
false,
"browser",
"download"
),
]
}
fn network_node_types() -> Vec<NodeTypeInfo> {
vec![node_type!(
"http-request",
"HTTP Request",
"Make HTTP requests to APIs (GET, POST, PUT, DELETE, etc.).",
NodeCategory::Network,
false,
"server",
"globe"
)]
}
fn spreadsheet_node_types() -> Vec<NodeTypeInfo> {
vec![
node_type!(
"spreadsheet-clean",
"Clean CSV",
"Remove empty rows, trim whitespace, and deduplicate CSV data.",
NodeCategory::Spreadsheet,
false,
"browser",
"sheet"
),
node_type!(
"spreadsheet-convert",
"CSV to JSON",
"Convert CSV data to JSON format with configurable delimiters.",
NodeCategory::Spreadsheet,
false,
"browser",
"sheet"
),
node_type!(
"spreadsheet-merge",
"Merge CSV",
"Combine multiple CSV files into one with header reconciliation and deduplication.",
NodeCategory::Spreadsheet,
false,
"browser",
"sheet"
),
node_type!(
"spreadsheet-rename",
"Rename CSV Columns",
"Rename column headers in a CSV file.",
NodeCategory::Spreadsheet,
false,
"browser",
"sheet"
),
]
}
fn system_node_types() -> Vec<NodeTypeInfo> {
vec![node_type!(
"shell-command",
"Shell Command",
"Execute shell commands with stall detection, retry, and streaming output.",
NodeCategory::System,
false,
"server",
"terminal"
)]
}
fn video_node_types() -> Vec<NodeTypeInfo> {
vec![node_type!(
"video-download",
"Download Video",
"Download video from URLs using yt-dlp (CLI/desktop only).",
NodeCategory::Video,
false,
"server",
"video"
)]
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Dependency {
pub binary: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub version: String,
pub install_hint: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub homepage: String,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct NodeMetadata {
pub node_type: std::string::String,
pub name: std::string::String,
pub description: std::string::String,
pub category: NodeCategory,
pub accepts: Vec<std::string::String>,
pub platforms: Vec<std::string::String>,
pub parameters: Vec<ParameterDef>,
#[serde(default)]
pub input_cardinality: InputCardinality,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requires: Vec<Dependency>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_input_cardinality_defaults_to_per_file() {
let cardinality = InputCardinality::default();
assert_eq!(cardinality, InputCardinality::PerFile);
}
#[test]
fn test_input_cardinality_serializes_camel_case() {
let per_file = serde_json::to_string(&InputCardinality::PerFile).unwrap();
assert_eq!(per_file, r#""perFile""#);
let batch = serde_json::to_string(&InputCardinality::Batch).unwrap();
assert_eq!(batch, r#""batch""#);
let source = serde_json::to_string(&InputCardinality::Source).unwrap();
assert_eq!(source, r#""source""#);
}
#[test]
fn test_metadata_with_input_cardinality_round_trip() {
let metadata = NodeMetadata {
node_type: "image-compress".to_string(),
name: "Compress Images".to_string(),
description: "Reduce image file size".to_string(),
category: NodeCategory::Image,
accepts: vec!["image/jpeg".to_string()],
platforms: vec!["browser".to_string()],
parameters: vec![],
input_cardinality: InputCardinality::PerFile,
requires: vec![],
};
let json = serde_json::to_string(&metadata).unwrap();
assert!(json.contains(r#""inputCardinality":"perFile""#));
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["inputCardinality"], "perFile");
}
#[test]
fn test_metadata_with_batch_cardinality() {
let metadata = NodeMetadata {
node_type: "zip-files".to_string(),
name: "Zip Files".to_string(),
description: "Bundle files into a zip archive".to_string(),
category: NodeCategory::File,
accepts: vec![],
platforms: vec!["browser".to_string()],
parameters: vec![],
input_cardinality: InputCardinality::Batch,
requires: vec![],
};
let json = serde_json::to_string(&metadata).unwrap();
assert!(json.contains(r#""inputCardinality":"batch""#));
}
#[test]
fn test_all_node_types_returns_20_entries() {
let types = all_node_types();
assert_eq!(types.len(), 20, "Should have exactly 20 node types");
}
#[test]
fn test_all_node_types_sorted_alphabetically() {
let types = all_node_types();
let names: Vec<&str> = types.iter().map(|t| t.name.as_str()).collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted, "Node types should be alphabetically sorted");
}
#[test]
fn test_all_node_types_unique_names() {
let types = all_node_types();
let mut names: Vec<&str> = types.iter().map(|t| t.name.as_str()).collect();
names.sort();
names.dedup();
assert_eq!(names.len(), 20, "All node type names should be unique");
}
#[test]
fn test_container_types_are_group_loop_parallel() {
let types = all_node_types();
let mut containers: Vec<&str> = types
.iter()
.filter(|t| t.is_container)
.map(|t| t.name.as_str())
.collect();
containers.sort();
assert_eq!(containers, vec!["group", "loop", "parallel"]);
}
#[test]
fn test_io_types_are_input_output() {
let types = all_node_types();
let mut io_types: Vec<&str> = types
.iter()
.filter(|t| t.category == NodeCategory::Io)
.map(|t| t.name.as_str())
.collect();
io_types.sort();
assert_eq!(io_types, vec!["input", "output"]);
}
#[test]
fn test_server_only_types() {
let types = all_node_types();
let mut server_only: Vec<&str> = types
.iter()
.filter(|t| !t.platforms.contains(&"browser".to_string()))
.map(|t| t.name.as_str())
.collect();
server_only.sort();
assert_eq!(
server_only,
vec!["http-request", "shell-command", "video-download"]
);
}
#[test]
fn test_node_type_info_serializes_camel_case() {
let info = NodeTypeInfo {
name: "image".to_string(),
label: "Image".to_string(),
description: "Image processing".to_string(),
category: NodeCategory::Image,
is_container: false,
platforms: vec!["browser".to_string()],
icon: "image".to_string(),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains(r#""isContainer":false"#));
assert!(!json.contains("is_container"));
}
#[test]
fn test_category_serializes_to_kebab_case() {
let json = serde_json::to_string(&NodeCategory::Image).unwrap();
assert_eq!(json, r#""image""#);
let json = serde_json::to_string(&NodeCategory::Spreadsheet).unwrap();
assert_eq!(json, r#""spreadsheet""#);
let json = serde_json::to_string(&NodeCategory::File).unwrap();
assert_eq!(json, r#""file""#);
let json = serde_json::to_string(&NodeCategory::Io).unwrap();
assert_eq!(json, r#""io""#);
let json = serde_json::to_string(&NodeCategory::Video).unwrap();
assert_eq!(json, r#""video""#);
}
#[test]
fn test_parameter_type_number_serialization() {
let json = serde_json::to_string(&ParameterType::Number).unwrap();
assert_eq!(json, r#"{"type":"number"}"#);
}
#[test]
fn test_parameter_type_enum_serialization() {
let param = ParameterType::Enum {
options: vec!["jpeg".to_string(), "png".to_string(), "webp".to_string()],
};
let json = serde_json::to_string(¶m).unwrap();
assert!(json.contains(r#""type":"enum""#));
assert!(json.contains(r#""options":["jpeg","png","webp"]"#));
}
#[test]
fn test_constraints_skips_none_fields() {
let constraints = Constraints {
min: Some(1.0),
max: None,
required: false,
};
let json = serde_json::to_string(&constraints).unwrap();
assert!(json.contains(r#""min":1.0"#));
assert!(!json.contains("max"));
assert!(json.contains(r#""required":false"#));
}
#[test]
fn test_constraints_includes_all_fields_when_present() {
let constraints = Constraints {
min: Some(1.0),
max: Some(100.0),
required: true,
};
let json = serde_json::to_string(&constraints).unwrap();
assert!(json.contains(r#""min":1.0"#));
assert!(json.contains(r#""max":100.0"#));
assert!(json.contains(r#""required":true"#));
}
#[test]
fn test_parameter_def_serializes_camel_case() {
let param = ParameterDef {
name: "quality".to_string(),
label: "Quality".to_string(),
description: "Compression quality".to_string(),
param_type: ParameterType::Number,
default: Some(serde_json::json!(80)),
constraints: Some(Constraints {
min: Some(1.0),
max: Some(100.0),
required: false,
}),
..Default::default()
};
let json = serde_json::to_string(¶m).unwrap();
assert!(json.contains(r#""paramType""#));
assert!(!json.contains("param_type"));
}
#[test]
fn test_parameter_def_skips_none_default() {
let param = ParameterDef {
name: "width".to_string(),
label: "Width".to_string(),
description: "Target width".to_string(),
param_type: ParameterType::Number,
..Default::default()
};
let json = serde_json::to_string(¶m).unwrap();
assert!(!json.contains("default"));
assert!(!json.contains("constraints"));
assert!(!json.contains("placeholder"));
assert!(!json.contains("visibleWhen"));
assert!(!json.contains("requiredWhen"));
}
#[test]
fn test_parameter_def_surfaceable_defaults_to_true() {
let param = ParameterDef {
name: "quality".to_string(),
label: "Quality".to_string(),
description: "Compression quality".to_string(),
param_type: ParameterType::Number,
..Default::default()
};
assert!(param.surfaceable, "surfaceable should default to true");
let json = serde_json::to_string(¶m).unwrap();
assert!(json.contains(r#""surfaceable":true"#));
}
#[test]
fn test_parameter_def_surfaceable_false_serializes() {
let param = ParameterDef {
name: "items".to_string(),
label: "Items".to_string(),
description: "Template expression for iteration items".to_string(),
param_type: ParameterType::String,
surfaceable: false,
..Default::default()
};
assert!(!param.surfaceable);
let json = serde_json::to_string(¶m).unwrap();
assert!(json.contains(r#""surfaceable":false"#));
}
#[test]
fn test_node_metadata_serializes_camel_case() {
let metadata = NodeMetadata {
node_type: "image-compress".to_string(),
name: "Compress Images".to_string(),
description: "Reduce image file size".to_string(),
category: NodeCategory::Image,
accepts: vec![
"image/jpeg".to_string(),
"image/png".to_string(),
"image/webp".to_string(),
],
platforms: vec!["browser".to_string()],
parameters: vec![],
input_cardinality: InputCardinality::PerFile,
requires: vec![],
};
let json = serde_json::to_string(&metadata).unwrap();
assert!(json.contains(r#""nodeType":"image-compress""#));
assert!(json.contains(r#""platforms":["browser"]"#));
assert!(!json.contains("node_type"));
}
#[test]
fn test_full_metadata_round_trip() {
let metadata = NodeMetadata {
node_type: "image-compress".to_string(),
name: "Compress Images".to_string(),
description: "Reduce image file size while maintaining quality".to_string(),
category: NodeCategory::Image,
accepts: vec![
"image/jpeg".to_string(),
"image/png".to_string(),
"image/webp".to_string(),
],
platforms: vec!["browser".to_string()],
parameters: vec![ParameterDef {
name: "quality".to_string(),
label: "Quality".to_string(),
description: "Compression quality (1-100)".to_string(),
param_type: ParameterType::Number,
default: Some(serde_json::json!(80)),
constraints: Some(Constraints {
min: Some(1.0),
max: Some(100.0),
required: false,
}),
..Default::default()
}],
input_cardinality: InputCardinality::PerFile,
requires: vec![],
};
let json = serde_json::to_string_pretty(&metadata).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["nodeType"], "image-compress");
assert_eq!(parsed["category"], "image");
assert_eq!(parsed["platforms"][0], "browser");
assert_eq!(parsed["accepts"].as_array().unwrap().len(), 3);
assert_eq!(parsed["parameters"].as_array().unwrap().len(), 1);
assert_eq!(parsed["parameters"][0]["name"], "quality");
assert_eq!(parsed["parameters"][0]["default"], 80);
}
#[test]
fn test_param_condition_single_serializes_as_object() {
let condition = ParamCondition::Single(ParamConditionEntry {
param: "operation".to_string(),
equals: "resize".to_string(),
});
let json = serde_json::to_string(&condition).unwrap();
assert_eq!(json, r#"{"param":"operation","equals":"resize"}"#);
}
#[test]
fn test_param_condition_any_serializes_as_array() {
let condition = ParamCondition::Any(vec![
ParamConditionEntry {
param: "operation".to_string(),
equals: "resize".to_string(),
},
ParamConditionEntry {
param: "operation".to_string(),
equals: "crop".to_string(),
},
]);
let json = serde_json::to_string(&condition).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.is_array(), "Any condition should be a JSON array");
assert_eq!(parsed.as_array().unwrap().len(), 2);
assert_eq!(parsed[0]["param"], "operation");
assert_eq!(parsed[0]["equals"], "resize");
assert_eq!(parsed[1]["equals"], "crop");
}
#[test]
fn test_parameter_def_with_ui_fields_serializes_camel_case() {
let param = ParameterDef {
name: "width".to_string(),
label: "Width".to_string(),
description: "Target width in pixels".to_string(),
param_type: ParameterType::Number,
default: None,
constraints: None,
placeholder: Some("e.g. 800".to_string()),
visible_when: Some(ParamCondition::Single(ParamConditionEntry {
param: "operation".to_string(),
equals: "resize".to_string(),
})),
..Default::default()
};
let json = serde_json::to_string(¶m).unwrap();
assert!(json.contains(r#""visibleWhen""#));
assert!(!json.contains("visible_when"));
assert!(json.contains(r#""placeholder":"e.g. 800""#));
assert!(!json.contains("requiredWhen"));
}
#[test]
fn test_dependency_serializes_camel_case() {
let dep = Dependency {
binary: "yt-dlp".to_string(),
version: ">=2023.01.01".to_string(),
install_hint: "brew install yt-dlp".to_string(),
homepage: "https://github.com/yt-dlp/yt-dlp".to_string(),
};
let json = serde_json::to_string(&dep).unwrap();
assert!(json.contains(r#""binary":"yt-dlp""#));
assert!(json.contains(r#""version":">=2023.01.01""#));
assert!(json.contains(r#""installHint":"brew install yt-dlp""#));
assert!(json.contains(r#""homepage":"https://github.com/yt-dlp/yt-dlp""#));
assert!(!json.contains("install_hint"));
}
#[test]
fn test_dependency_skips_empty_optional_fields() {
let dep = Dependency {
binary: "ffmpeg".to_string(),
version: String::new(),
install_hint: "brew install ffmpeg".to_string(),
homepage: String::new(),
};
let json = serde_json::to_string(&dep).unwrap();
assert!(!json.contains("version"), "empty version should be omitted");
assert!(
!json.contains("homepage"),
"empty homepage should be omitted"
);
assert!(json.contains(r#""binary":"ffmpeg""#));
assert!(json.contains(r#""installHint""#));
}
#[test]
fn test_dependency_equality() {
let a = Dependency {
binary: "yt-dlp".to_string(),
version: ">=2023.01.01".to_string(),
install_hint: "brew install yt-dlp".to_string(),
homepage: "https://github.com/yt-dlp/yt-dlp".to_string(),
};
let b = a.clone();
assert_eq!(a, b);
}
#[test]
fn test_metadata_requires_empty_skipped_in_serialization() {
let metadata = NodeMetadata {
node_type: "image-compress".to_string(),
name: "Compress".to_string(),
description: String::new(),
category: NodeCategory::Image,
accepts: vec![],
platforms: vec!["browser".to_string()],
parameters: vec![],
input_cardinality: InputCardinality::PerFile,
requires: vec![],
};
let json = serde_json::to_string(&metadata).unwrap();
assert!(
!json.contains("requires"),
"empty requires should be omitted"
);
}
#[test]
fn test_metadata_requires_present_when_populated() {
let metadata = NodeMetadata {
node_type: "video-download".to_string(),
name: "Download Video".to_string(),
description: String::new(),
category: NodeCategory::Network,
accepts: vec![],
platforms: vec!["server".to_string()],
parameters: vec![],
input_cardinality: InputCardinality::PerFile,
requires: vec![Dependency {
binary: "yt-dlp".to_string(),
version: ">=2023.01.01".to_string(),
install_hint: "brew install yt-dlp".to_string(),
homepage: "https://github.com/yt-dlp/yt-dlp".to_string(),
}],
};
let json = serde_json::to_string(&metadata).unwrap();
assert!(json.contains(r#""requires""#));
assert!(json.contains(r#""binary":"yt-dlp""#));
assert!(json.contains(r#""version":">=2023.01.01""#));
}
}