use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[cfg(feature = "ts")]
use ts_rs::TS;
use crate::field_def::FieldDef;
use crate::secrets::SecretDef;
#[cfg_attr(feature = "ts", derive(TS))]
#[cfg_attr(
feature = "ts",
ts(
export,
export_to = "../../../../packages/@bnto/nodes/src/generated/definitionTypes/"
)
)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum IterationMode {
#[default]
Explicit,
Auto,
}
#[cfg_attr(feature = "ts", derive(TS))]
#[cfg_attr(
feature = "ts",
ts(
export,
export_to = "../../../../packages/@bnto/nodes/src/generated/definitionTypes/"
)
)]
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PipelineSettings {
#[serde(default)]
pub iteration: IterationMode,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PipelineDefinition {
pub nodes: Vec<PipelineNode>,
#[serde(default)]
pub settings: Option<PipelineSettings>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requires: Vec<crate::Dependency>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secrets: Vec<SecretDef>,
}
impl PipelineDefinition {
pub fn resolved_iteration(&self) -> IterationMode {
self.settings
.as_ref()
.map(|s| s.iteration)
.unwrap_or_default()
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PipelineNode {
pub id: String,
#[serde(rename = "type")]
pub node_type: String,
#[serde(default, alias = "parameters")]
pub params: serde_json::Map<String, serde_json::Value>,
#[serde(alias = "nodes")]
pub children: Option<Vec<PipelineNode>>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub fields: BTreeMap<String, FieldDef>,
}
#[derive(Debug, Clone)]
pub struct PipelineFile {
pub name: String,
pub data: crate::processor::FileData,
pub mime_type: String,
pub metadata: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct PipelineFileResult {
pub name: String,
pub data: crate::processor::FileData,
pub mime_type: String,
pub metadata: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct PipelineResult {
pub files: Vec<PipelineFileResult>,
pub duration_ms: u64,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InputMode {
#[default]
FileUpload,
Url,
Text,
}
pub fn resolve_input_mode(def: &PipelineDefinition) -> InputMode {
find_input_mode_in_nodes(&def.nodes)
}
fn find_input_mode_in_nodes(nodes: &[PipelineNode]) -> InputMode {
for node in nodes {
if node.node_type == "input" {
return match node.params.get("mode").and_then(|v| v.as_str()) {
Some("url") => InputMode::Url,
Some("text") => InputMode::Text,
_ => InputMode::FileUpload,
};
}
if let Some(children) = &node.children {
let mode = find_input_mode_in_nodes(children);
if mode != InputMode::FileUpload {
return mode;
}
if children.iter().any(|c| c.node_type == "input") {
return InputMode::FileUpload;
}
}
}
InputMode::FileUpload
}
pub fn first_processing_node_id(def: &PipelineDefinition) -> Option<String> {
find_first_processing_in_nodes(&def.nodes)
}
fn find_first_processing_in_nodes(nodes: &[PipelineNode]) -> Option<String> {
for node in nodes {
if is_io_node(&node.node_type) {
continue;
}
if is_container_node(&node.node_type) {
if let Some(children) = &node.children
&& let Some(id) = find_first_processing_in_nodes(children)
{
return Some(id);
}
continue;
}
return Some(node.id.clone());
}
None
}
pub fn resolve_output_directory(def: &PipelineDefinition) -> Option<String> {
find_output_directory_in_nodes(&def.nodes)
}
fn find_output_directory_in_nodes(nodes: &[PipelineNode]) -> Option<String> {
for node in nodes {
if node.node_type == "output" {
let dir = node
.params
.get("directory")
.and_then(|v| v.as_str())
.unwrap_or("");
return if dir.is_empty() {
None
} else {
Some(dir.to_string())
};
}
if let Some(children) = &node.children
&& let Some(dir) = find_output_directory_in_nodes(children)
{
return Some(dir);
}
}
None
}
pub fn resolve_output_mode(def: &PipelineDefinition) -> String {
find_output_mode_in_nodes(&def.nodes).unwrap_or_else(|| "write".to_string())
}
fn find_output_mode_in_nodes(nodes: &[PipelineNode]) -> Option<String> {
for node in nodes {
if node.node_type == "output" {
let mode = node
.params
.get("mode")
.and_then(|v| v.as_str())
.unwrap_or("write");
return if mode.is_empty() {
Some("write".to_string())
} else {
Some(mode.to_string())
};
}
if let Some(children) = &node.children
&& let Some(mode) = find_output_mode_in_nodes(children)
{
return Some(mode);
}
}
None
}
pub fn is_io_node(node_type: &str) -> bool {
node_type == "input" || node_type == "output"
}
pub fn is_container_node(node_type: &str) -> bool {
node_type == "loop" || node_type == "group" || node_type == "parallel"
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_definition(json: &str) -> PipelineDefinition {
serde_json::from_str(json).unwrap()
}
#[test]
fn test_simple_definition_deserializes() {
let json = r#"{
"nodes": [
{ "id": "n1", "type": "input" },
{ "id": "n2", "type": "image-compress", "params": { "quality": 80 } },
{ "id": "n3", "type": "output" }
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
assert_eq!(def.nodes.len(), 3);
assert_eq!(def.nodes[0].id, "n1");
assert_eq!(def.nodes[0].node_type, "input");
assert_eq!(def.nodes[1].id, "n2");
assert_eq!(def.nodes[1].node_type, "image-compress");
assert_eq!(def.nodes[2].id, "n3");
assert_eq!(def.nodes[2].node_type, "output");
}
#[test]
fn test_params_deserialize_correctly() {
let json = r#"{
"nodes": [
{
"id": "n1",
"type": "image-compress",
"params": {
"quality": 80,
"preserveExif": true
}
}
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let params = &def.nodes[0].params;
assert_eq!(params["quality"], 80);
assert_eq!(params["preserveExif"], true);
}
#[test]
fn test_missing_params_defaults_to_empty() {
let json = r#"{
"nodes": [
{ "id": "n1", "type": "input" }
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
assert!(def.nodes[0].params.is_empty());
}
#[test]
fn test_container_node_with_children() {
let json = r#"{
"nodes": [
{
"id": "loop-1",
"type": "loop",
"children": [
{ "id": "child-1", "type": "image-compress" }
]
}
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let loop_node = &def.nodes[0];
assert_eq!(loop_node.node_type, "loop");
let children = loop_node.children.as_ref().unwrap();
assert_eq!(children.len(), 1);
assert_eq!(children[0].node_type, "image-compress");
}
#[test]
fn test_no_children_is_none() {
let json = r#"{
"nodes": [
{ "id": "n1", "type": "image-compress" }
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
assert!(def.nodes[0].children.is_none());
}
#[test]
fn test_nested_containers() {
let json = r#"{
"nodes": [
{
"id": "group-1",
"type": "group",
"children": [
{
"id": "loop-1",
"type": "loop",
"children": [
{ "id": "proc-1", "type": "image-compress" }
]
}
]
}
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let group = &def.nodes[0];
let loop_node = &group.children.as_ref().unwrap()[0];
let proc_node = &loop_node.children.as_ref().unwrap()[0];
assert_eq!(group.node_type, "group");
assert_eq!(loop_node.node_type, "loop");
assert_eq!(proc_node.node_type, "image-compress");
}
#[test]
fn test_nodes_alias_deserializes_as_children() {
let json = r#"{
"nodes": [
{
"id": "loop-1",
"type": "loop",
"nodes": [
{ "id": "child-1", "type": "image-compress" }
]
}
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let loop_node = &def.nodes[0];
let children = loop_node.children.as_ref().unwrap();
assert_eq!(children.len(), 1);
assert_eq!(children[0].id, "child-1");
assert_eq!(children[0].node_type, "image-compress");
}
#[test]
fn test_parameters_alias_deserializes_as_params() {
let json = r#"{
"nodes": [
{
"id": "n1",
"type": "image-compress",
"parameters": { "quality": 80 }
}
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let params = &def.nodes[0].params;
assert_eq!(params["quality"], 80);
}
#[test]
fn test_both_aliases_together() {
let json = r#"{
"nodes": [
{
"id": "loop-1",
"type": "loop",
"parameters": { "mode": "forEach" },
"nodes": [
{
"id": "child-1",
"type": "image-compress",
"parameters": { "quality": 75 }
}
]
}
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let loop_node = &def.nodes[0];
assert_eq!(loop_node.params["mode"], "forEach");
let children = loop_node.children.as_ref().unwrap();
assert_eq!(children.len(), 1);
assert_eq!(children[0].params["quality"], 75);
}
#[test]
fn test_original_field_names_still_work() {
let json = r#"{
"nodes": [
{
"id": "loop-1",
"type": "loop",
"params": { "mode": "forEach" },
"children": [
{ "id": "child-1", "type": "image-compress" }
]
}
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let loop_node = &def.nodes[0];
assert_eq!(loop_node.params["mode"], "forEach");
assert_eq!(loop_node.children.as_ref().unwrap().len(), 1);
}
#[test]
fn test_unknown_fields_silently_ignored() {
let json = r#"{
"nodes": [
{
"id": "compress-image",
"type": "image-compress",
"version": "1.0.0",
"name": "Compress Image",
"position": { "x": 100, "y": 100 },
"metadata": { "description": "Compresses images" },
"parameters": { "quality": 80 },
"inputPorts": [{ "id": "in-1", "name": "files" }],
"outputPorts": [{ "id": "out-1", "name": "files" }]
}
],
"edges": [{ "id": "e1", "source": "input", "target": "compress-image" }]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
assert_eq!(def.nodes.len(), 1);
assert_eq!(def.nodes[0].id, "compress-image");
assert_eq!(def.nodes[0].params["quality"], 80);
}
#[test]
fn test_compress_images_recipe_deserializes() {
let json = r#"{
"nodes": [
{
"id": "input", "type": "input", "version": "1.0.0",
"name": "Input Files", "position": {"x": 0, "y": 100},
"metadata": {},
"parameters": { "mode": "file-upload", "accept": ["image/jpeg"] },
"inputPorts": [], "outputPorts": [{"id": "out-1", "name": "files"}]
},
{
"id": "batch-compress", "type": "group", "version": "1.0.0",
"name": "Batch Compress", "position": {"x": 250, "y": 100},
"metadata": { "description": "Reusable sub-recipe." },
"parameters": {},
"inputPorts": [{"id": "in-1", "name": "files"}],
"outputPorts": [{"id": "out-1", "name": "files"}],
"nodes": [
{
"id": "compress-loop", "type": "loop", "version": "1.0.0",
"name": "Compress Each Image", "position": {"x": 0, "y": 0},
"metadata": {},
"parameters": { "mode": "forEach" },
"inputPorts": [{"id": "in-1", "name": "items"}], "outputPorts": [],
"nodes": [
{
"id": "compress-image", "type": "image-compress", "version": "1.0.0",
"name": "Compress Image", "position": {"x": 0, "y": 0},
"metadata": {},
"parameters": { "quality": 80 },
"inputPorts": [], "outputPorts": []
}
],
"edges": []
}
],
"edges": []
},
{
"id": "output", "type": "output", "version": "1.0.0",
"name": "Compressed Images", "position": {"x": 500, "y": 100},
"metadata": {},
"parameters": { "mode": "write", "zip": true },
"inputPorts": [{"id": "in-1", "name": "files"}], "outputPorts": []
}
],
"edges": [
{"id": "e1", "source": "input", "target": "batch-compress"},
{"id": "e2", "source": "batch-compress", "target": "output"}
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
assert_eq!(def.nodes.len(), 3);
assert_eq!(def.nodes[0].node_type, "input");
assert_eq!(def.nodes[1].node_type, "group");
assert_eq!(def.nodes[1].id, "batch-compress");
assert_eq!(def.nodes[2].node_type, "output");
let group_children = def.nodes[1].children.as_ref().unwrap();
assert_eq!(group_children.len(), 1);
assert_eq!(group_children[0].node_type, "loop");
let loop_children = group_children[0].children.as_ref().unwrap();
assert_eq!(loop_children.len(), 1);
assert_eq!(loop_children[0].id, "compress-image");
assert_eq!(loop_children[0].node_type, "image-compress");
assert_eq!(loop_children[0].params["quality"], 80);
}
#[test]
fn test_clean_csv_recipe_deserializes() {
let json = r#"{
"nodes": [
{
"id": "input", "type": "input", "version": "1.0.0",
"name": "Input Files", "position": {"x": 0, "y": 100},
"metadata": {},
"parameters": { "mode": "file-upload" },
"inputPorts": [], "outputPorts": [{"id": "out-1", "name": "files"}]
},
{
"id": "csv-cleaner", "type": "group", "version": "1.0.0",
"name": "CSV Cleaner", "position": {"x": 250, "y": 100},
"metadata": {},
"parameters": {},
"inputPorts": [{"id": "in-1", "name": "files"}],
"outputPorts": [{"id": "out-1", "name": "files"}],
"nodes": [
{
"id": "clean", "type": "spreadsheet-clean", "version": "1.0.0",
"name": "Clean CSV", "position": {"x": 0, "y": 0},
"metadata": {},
"parameters": {
"trimWhitespace": true,
"removeEmptyRows": true,
"removeDuplicates": true
},
"inputPorts": [{"id": "in-1", "name": "files"}],
"outputPorts": [{"id": "out-1", "name": "files"}]
}
],
"edges": []
},
{
"id": "output", "type": "output", "version": "1.0.0",
"name": "Cleaned CSV", "position": {"x": 500, "y": 100},
"metadata": {},
"parameters": { "mode": "write" },
"inputPorts": [{"id": "in-1", "name": "files"}], "outputPorts": []
}
],
"edges": [
{"id": "e1", "source": "input", "target": "csv-cleaner"},
{"id": "e2", "source": "csv-cleaner", "target": "output"}
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
assert_eq!(def.nodes.len(), 3);
assert_eq!(def.nodes[1].node_type, "group");
assert_eq!(def.nodes[1].id, "csv-cleaner");
let group_children = def.nodes[1].children.as_ref().unwrap();
assert_eq!(group_children.len(), 1);
assert_eq!(group_children[0].node_type, "spreadsheet-clean");
}
#[test]
fn test_rename_files_recipe_deserializes() {
let json = r#"{
"nodes": [
{ "id": "input", "type": "input", "version": "1.0.0",
"name": "Input", "position": {"x": 0, "y": 0}, "metadata": {},
"parameters": {}, "inputPorts": [], "outputPorts": [] },
{
"id": "batch-rename", "type": "group", "version": "1.0.0",
"name": "Batch Rename", "position": {"x": 250, "y": 100},
"metadata": {},
"parameters": {},
"inputPorts": [], "outputPorts": [],
"nodes": [
{
"id": "rename-loop", "type": "loop", "version": "1.0.0",
"name": "Rename Each File", "position": {"x": 0, "y": 0},
"metadata": {},
"parameters": { "mode": "forEach" },
"inputPorts": [], "outputPorts": [],
"nodes": [
{
"id": "rename-file", "type": "file-rename", "version": "1.0.0",
"name": "Rename File", "position": {"x": 0, "y": 0},
"metadata": {},
"parameters": { "prefix": "renamed-" },
"inputPorts": [], "outputPorts": []
}
],
"edges": []
}
],
"edges": []
},
{ "id": "output", "type": "output", "version": "1.0.0",
"name": "Output", "position": {"x": 0, "y": 0}, "metadata": {},
"parameters": {}, "inputPorts": [], "outputPorts": [] }
],
"edges": []
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let group_node = &def.nodes[1];
assert_eq!(group_node.node_type, "group");
assert_eq!(group_node.id, "batch-rename");
let group_children = group_node.children.as_ref().unwrap();
assert_eq!(group_children.len(), 1);
assert_eq!(group_children[0].node_type, "loop");
let loop_children = group_children[0].children.as_ref().unwrap();
assert_eq!(loop_children.len(), 1);
assert_eq!(loop_children[0].node_type, "file-rename");
assert_eq!(loop_children[0].params["prefix"], "renamed-");
}
#[test]
fn test_deeply_nested_three_levels() {
let json = r#"{
"nodes": [
{
"id": "outer-group", "type": "group",
"parameters": {},
"nodes": [
{
"id": "inner-group", "type": "group",
"parameters": {},
"nodes": [
{
"id": "the-loop", "type": "loop",
"parameters": { "mode": "forEach" },
"nodes": [
{
"id": "processor", "type": "image-compress",
"parameters": { "quality": 50 }
}
]
}
]
}
]
}
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let outer = &def.nodes[0];
assert_eq!(outer.node_type, "group");
let inner = &outer.children.as_ref().unwrap()[0];
assert_eq!(inner.node_type, "group");
let loop_node = &inner.children.as_ref().unwrap()[0];
assert_eq!(loop_node.node_type, "loop");
let processor = &loop_node.children.as_ref().unwrap()[0];
assert_eq!(processor.node_type, "image-compress");
assert_eq!(processor.params["quality"], 50);
}
#[test]
fn test_definition_without_requires_still_parses() {
let json = r#"{
"nodes": [
{ "id": "n1", "type": "input" },
{ "id": "n2", "type": "image-compress" }
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
assert!(def.requires.is_empty());
}
#[test]
fn test_definition_with_requires_parses() {
let json = r#"{
"requires": [
{
"binary": "yt-dlp",
"installHint": "brew install yt-dlp",
"homepage": "https://github.com/yt-dlp/yt-dlp"
}
],
"nodes": [
{ "id": "n1", "type": "input" }
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
assert_eq!(def.requires.len(), 1);
assert_eq!(def.requires[0].binary, "yt-dlp");
assert_eq!(def.requires[0].install_hint, "brew install yt-dlp");
}
#[test]
fn test_definition_with_multiple_requires() {
let json = r#"{
"requires": [
{ "binary": "yt-dlp", "installHint": "brew install yt-dlp" },
{ "binary": "ffmpeg", "installHint": "brew install ffmpeg", "version": ">=6.0" }
],
"nodes": [{ "id": "n1", "type": "input" }]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
assert_eq!(def.requires.len(), 2);
assert_eq!(def.requires[0].binary, "yt-dlp");
assert_eq!(def.requires[1].binary, "ffmpeg");
assert_eq!(def.requires[1].version, ">=6.0");
}
#[test]
fn test_definition_empty_requires_omitted_in_serialization() {
let json = r#"{ "nodes": [{ "id": "n1", "type": "input" }] }"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let serialized = serde_json::to_string(&def).unwrap();
assert!(
!serialized.contains("requires"),
"Empty requires should be omitted; got: {serialized}"
);
}
#[test]
fn test_definition_requires_round_trip() {
let json = r#"{
"requires": [
{
"binary": "yt-dlp",
"version": ">=2024.0.0",
"installHint": "brew install yt-dlp",
"homepage": "https://github.com/yt-dlp/yt-dlp"
}
],
"nodes": [{ "id": "n1", "type": "input" }]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let serialized = serde_json::to_string(&def).unwrap();
let round_tripped: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
assert_eq!(round_tripped.requires.len(), 1);
assert_eq!(round_tripped.requires[0].binary, "yt-dlp");
assert_eq!(round_tripped.requires[0].version, ">=2024.0.0");
assert_eq!(
round_tripped.requires[0].install_hint,
"brew install yt-dlp"
);
assert_eq!(
round_tripped.requires[0].homepage,
"https://github.com/yt-dlp/yt-dlp"
);
}
#[test]
fn test_definition_requires_preserves_all_dependency_fields() {
let json = r#"{
"requires": [
{
"binary": "ffmpeg",
"version": ">=6.0",
"installHint": "brew install ffmpeg",
"homepage": "https://ffmpeg.org"
}
],
"nodes": []
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let dep = &def.requires[0];
assert_eq!(dep.binary, "ffmpeg");
assert_eq!(dep.version, ">=6.0");
assert_eq!(dep.install_hint, "brew install ffmpeg");
assert_eq!(dep.homepage, "https://ffmpeg.org");
}
#[test]
fn test_definition_without_secrets_still_parses() {
let json = r#"{
"nodes": [{ "id": "n1", "type": "input" }]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
assert!(def.secrets.is_empty());
}
#[test]
fn test_definition_with_secrets_parses() {
let json = r#"{
"secrets": [
{ "key": "OPENAI_API_KEY", "description": "OpenAI API key", "required": true }
],
"nodes": [{ "id": "n1", "type": "input" }]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
assert_eq!(def.secrets.len(), 1);
assert_eq!(def.secrets[0].key, "OPENAI_API_KEY");
assert!(def.secrets[0].required);
}
#[test]
fn test_definition_secrets_defaults_required_true() {
let json = r#"{
"secrets": [{ "key": "API_KEY" }],
"nodes": [{ "id": "n1", "type": "input" }]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
assert!(def.secrets[0].required);
assert!(def.secrets[0].description.is_empty());
}
#[test]
fn test_definition_empty_secrets_omitted_in_serialization() {
let json = r#"{ "nodes": [{ "id": "n1", "type": "input" }] }"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let serialized = serde_json::to_string(&def).unwrap();
assert!(
!serialized.contains("secrets"),
"Empty secrets should be omitted; got: {serialized}"
);
}
#[test]
fn test_definition_secrets_round_trip() {
let json = r#"{
"secrets": [
{ "key": "API_KEY", "description": "Test key", "required": true },
{ "key": "OPTIONAL", "required": false }
],
"nodes": [{ "id": "n1", "type": "input" }]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let serialized = serde_json::to_string(&def).unwrap();
let rt: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
assert_eq!(rt.secrets.len(), 2);
assert_eq!(rt.secrets[0].key, "API_KEY");
assert!(rt.secrets[0].required);
assert_eq!(rt.secrets[1].key, "OPTIONAL");
assert!(!rt.secrets[1].required);
}
#[test]
fn test_definition_without_settings_deserializes() {
let json = r#"{
"nodes": [
{ "id": "n1", "type": "input" },
{ "id": "n2", "type": "image-compress" },
{ "id": "n3", "type": "output" }
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
assert!(def.settings.is_none());
}
#[test]
fn test_definition_with_auto_iteration_deserializes() {
let json = r#"{
"settings": { "iteration": "auto" },
"nodes": [
{ "id": "n1", "type": "input" },
{ "id": "n2", "type": "image-compress" },
{ "id": "n3", "type": "output" }
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let settings = def.settings.as_ref().unwrap();
assert_eq!(settings.iteration, IterationMode::Auto);
}
#[test]
fn test_definition_with_explicit_iteration_deserializes() {
let json = r#"{
"settings": { "iteration": "explicit" },
"nodes": [
{ "id": "n1", "type": "image-compress" }
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let settings = def.settings.as_ref().unwrap();
assert_eq!(settings.iteration, IterationMode::Explicit);
}
#[test]
fn test_definition_with_unknown_iteration_fails() {
let json = r#"{
"settings": { "iteration": "garbage" },
"nodes": [{ "id": "n1", "type": "input" }]
}"#;
let result = serde_json::from_str::<PipelineDefinition>(json);
assert!(result.is_err());
}
#[test]
fn test_resolved_iteration_defaults_explicit() {
let json = r#"{ "nodes": [] }"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
assert_eq!(def.resolved_iteration(), IterationMode::Explicit);
}
#[test]
fn test_resolved_iteration_returns_auto() {
let json = r#"{
"settings": { "iteration": "auto" },
"nodes": []
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
assert_eq!(def.resolved_iteration(), IterationMode::Auto);
}
#[test]
fn test_settings_with_default_iteration_field() {
let json = r#"{
"settings": {},
"nodes": []
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let settings = def.settings.as_ref().unwrap();
assert_eq!(settings.iteration, IterationMode::Explicit);
assert_eq!(def.resolved_iteration(), IterationMode::Explicit);
}
#[test]
fn test_definition_round_trip_serialization() {
let json = r#"{
"nodes": [
{ "id": "n1", "type": "input" },
{ "id": "n2", "type": "image-compress", "params": { "quality": 80 } },
{ "id": "n3", "type": "output" }
],
"settings": { "iteration": "auto" }
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let serialized = serde_json::to_string(&def).unwrap();
let round_tripped: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
assert_eq!(round_tripped.nodes.len(), def.nodes.len());
for (orig, rt) in def.nodes.iter().zip(round_tripped.nodes.iter()) {
assert_eq!(orig.id, rt.id);
assert_eq!(orig.node_type, rt.node_type);
}
assert_eq!(round_tripped.resolved_iteration(), def.resolved_iteration());
}
#[test]
fn test_definition_serialization_preserves_params() {
let json = r#"{
"nodes": [
{
"id": "n1",
"type": "image-compress",
"params": { "quality": 80, "preserveExif": true, "name": "test" }
}
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let serialized = serde_json::to_string(&def).unwrap();
let round_tripped: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
let params = &round_tripped.nodes[0].params;
assert_eq!(params["quality"], 80);
assert_eq!(params["preserveExif"], true);
assert_eq!(params["name"], "test");
}
#[test]
fn test_definition_serialization_preserves_children() {
let json = r#"{
"nodes": [
{
"id": "group-1",
"type": "group",
"children": [
{
"id": "loop-1",
"type": "loop",
"children": [
{ "id": "proc-1", "type": "image-compress", "params": { "quality": 50 } }
]
}
]
}
]
}"#;
let def: PipelineDefinition = serde_json::from_str(json).unwrap();
let serialized = serde_json::to_string(&def).unwrap();
let round_tripped: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
let group = &round_tripped.nodes[0];
assert_eq!(group.node_type, "group");
let loop_node = &group.children.as_ref().unwrap()[0];
assert_eq!(loop_node.node_type, "loop");
let proc_node = &loop_node.children.as_ref().unwrap()[0];
assert_eq!(proc_node.node_type, "image-compress");
assert_eq!(proc_node.params["quality"], 50);
}
#[test]
fn test_iteration_mode_serializes_camel_case() {
let auto_json = serde_json::to_string(&IterationMode::Auto).unwrap();
assert_eq!(auto_json, r#""auto""#);
let explicit_json = serde_json::to_string(&IterationMode::Explicit).unwrap();
assert_eq!(explicit_json, r#""explicit""#);
}
#[test]
fn test_pipeline_settings_serializes() {
let settings = PipelineSettings {
iteration: IterationMode::Auto,
};
let json = serde_json::to_string(&settings).unwrap();
assert!(json.contains(r#""iteration":"auto""#));
}
#[test]
fn test_is_io_node() {
assert!(is_io_node("input"));
assert!(is_io_node("output"));
assert!(!is_io_node("image-compress"));
assert!(!is_io_node("spreadsheet-clean"));
assert!(!is_io_node("loop"));
}
#[test]
fn test_is_container_node() {
assert!(is_container_node("loop"));
assert!(is_container_node("group"));
assert!(is_container_node("parallel"));
assert!(!is_container_node("image-compress"));
assert!(!is_container_node("input"));
assert!(!is_container_node("output"));
}
#[test]
fn test_resolve_output_directory_found() {
let def = parse_definition(
r#"{
"nodes": [
{ "id": "in", "type": "input", "params": {} },
{ "id": "proc", "type": "image-compress", "params": {} },
{ "id": "out", "type": "output", "params": { "directory": "{{ctx.date}}-output" } }
]
}"#,
);
assert_eq!(
resolve_output_directory(&def),
Some("{{ctx.date}}-output".to_string())
);
}
#[test]
fn test_resolve_output_directory_none_when_missing() {
let def = parse_definition(
r#"{
"nodes": [
{ "id": "in", "type": "input", "params": {} },
{ "id": "out", "type": "output", "params": { "mode": "write" } }
]
}"#,
);
assert_eq!(resolve_output_directory(&def), None);
}
#[test]
fn test_resolve_output_directory_none_when_empty_string() {
let def = parse_definition(
r#"{
"nodes": [
{ "id": "out", "type": "output", "params": { "directory": "" } }
]
}"#,
);
assert_eq!(resolve_output_directory(&def), None);
}
#[test]
fn test_resolve_output_directory_no_output_node() {
let def = parse_definition(
r#"{
"nodes": [
{ "id": "in", "type": "input", "params": {} },
{ "id": "proc", "type": "image-compress", "params": {} }
]
}"#,
);
assert_eq!(resolve_output_directory(&def), None);
}
#[test]
fn test_resolve_output_directory_nested() {
let def = parse_definition(
r#"{
"nodes": [
{
"id": "group-1",
"type": "group",
"params": {},
"children": [
{ "id": "out", "type": "output", "params": { "directory": "nested-dir" } }
]
}
]
}"#,
);
assert_eq!(
resolve_output_directory(&def),
Some("nested-dir".to_string())
);
}
#[test]
fn test_resolve_output_mode_returns_mode_from_output_node() {
let def = parse_definition(
r#"{
"nodes": [
{ "id": "in", "type": "input", "params": {} },
{ "id": "out", "type": "output", "params": { "mode": "overwrite" } }
]
}"#,
);
assert_eq!(resolve_output_mode(&def), "overwrite");
}
#[test]
fn test_resolve_output_mode_defaults_to_write() {
let def = parse_definition(
r#"{
"nodes": [
{ "id": "in", "type": "input", "params": {} },
{ "id": "proc", "type": "image-compress", "params": {} }
]
}"#,
);
assert_eq!(resolve_output_mode(&def), "write");
}
#[test]
fn test_resolve_output_mode_defaults_when_no_mode_param() {
let def = parse_definition(
r#"{
"nodes": [
{ "id": "out", "type": "output", "params": { "directory": "foo" } }
]
}"#,
);
assert_eq!(resolve_output_mode(&def), "write");
}
#[test]
fn test_resolve_output_mode_nested_in_container() {
let def = parse_definition(
r#"{
"nodes": [
{
"id": "group-1",
"type": "group",
"params": {},
"children": [
{ "id": "out", "type": "output", "params": { "mode": "none" } }
]
}
]
}"#,
);
assert_eq!(resolve_output_mode(&def), "none");
}
#[test]
fn test_resolve_input_mode_file_upload() {
let def = parse_definition(
r#"{
"formatVersion": "1.0.0",
"nodes": [
{ "id": "in", "type": "input", "params": { "mode": "file-upload" } },
{ "id": "proc", "type": "image-compress", "params": {} },
{ "id": "out", "type": "output", "params": {} }
]
}"#,
);
assert_eq!(resolve_input_mode(&def), InputMode::FileUpload);
}
#[test]
fn test_resolve_input_mode_url() {
let def = parse_definition(
r#"{
"formatVersion": "1.0.0",
"nodes": [
{ "id": "in", "type": "input", "params": { "mode": "url" } },
{ "id": "proc", "type": "video-download", "params": {} },
{ "id": "out", "type": "output", "params": {} }
]
}"#,
);
assert_eq!(resolve_input_mode(&def), InputMode::Url);
}
#[test]
fn test_resolve_input_mode_text() {
let def = parse_definition(
r#"{
"formatVersion": "1.0.0",
"nodes": [
{ "id": "in", "type": "input", "params": { "mode": "text" } },
{ "id": "proc", "type": "text-transform", "params": {} },
{ "id": "out", "type": "output", "params": {} }
]
}"#,
);
assert_eq!(resolve_input_mode(&def), InputMode::Text);
}
#[test]
fn test_resolve_input_mode_missing_defaults() {
let def = parse_definition(
r#"{
"formatVersion": "1.0.0",
"nodes": [
{ "id": "in", "type": "input", "params": {} },
{ "id": "proc", "type": "image-compress", "params": {} }
]
}"#,
);
assert_eq!(resolve_input_mode(&def), InputMode::FileUpload);
}
#[test]
fn test_resolve_input_mode_no_input_node() {
let def = parse_definition(
r#"{
"formatVersion": "1.0.0",
"nodes": [
{ "id": "proc", "type": "image-compress", "params": {} },
{ "id": "out", "type": "output", "params": {} }
]
}"#,
);
assert_eq!(resolve_input_mode(&def), InputMode::FileUpload);
}
#[test]
fn test_resolve_input_mode_nested() {
let def = parse_definition(
r#"{
"formatVersion": "1.0.0",
"nodes": [
{
"id": "group-1",
"type": "group",
"params": {},
"children": [
{ "id": "in", "type": "input", "params": { "mode": "url" } },
{ "id": "proc", "type": "video-download", "params": {} }
]
},
{ "id": "out", "type": "output", "params": {} }
]
}"#,
);
assert_eq!(resolve_input_mode(&def), InputMode::Url);
}
#[test]
fn test_first_processing_node_id_simple() {
let def = parse_definition(
r#"{
"formatVersion": "1.0.0",
"nodes": [
{ "id": "in", "type": "input", "params": {} },
{ "id": "compress", "type": "image-compress", "params": {} },
{ "id": "out", "type": "output", "params": {} }
]
}"#,
);
assert_eq!(first_processing_node_id(&def), Some("compress".to_string()));
}
#[test]
fn test_first_processing_node_id_none() {
let def = parse_definition(
r#"{
"formatVersion": "1.0.0",
"nodes": [
{ "id": "in", "type": "input", "params": {} },
{ "id": "out", "type": "output", "params": {} }
]
}"#,
);
assert_eq!(first_processing_node_id(&def), None);
}
#[test]
fn test_first_processing_node_id_nested() {
let def = parse_definition(
r#"{
"formatVersion": "1.0.0",
"nodes": [
{ "id": "in", "type": "input", "params": {} },
{
"id": "loop-1",
"type": "loop",
"params": {},
"children": [
{ "id": "resize", "type": "image-resize", "params": {} },
{ "id": "compress", "type": "image-compress", "params": {} }
]
},
{ "id": "out", "type": "output", "params": {} }
]
}"#,
);
assert_eq!(first_processing_node_id(&def), Some("resize".to_string()));
}
}