pub mod yaml;
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PortDirection {
Input,
Output,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Port {
pub id: String,
pub label: String,
pub direction: PortDirection,
#[serde(default)]
pub port_type: String,
#[serde(default)]
pub color: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldType {
Text,
Textarea,
Select,
Toggle,
Badge,
Slider,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FieldDef {
pub key: String,
pub field_type: FieldType,
pub label: String,
#[serde(default)]
pub options: Vec<String>,
#[serde(default)]
pub default_value: Option<serde_json::Value>,
#[serde(default)]
pub min: Option<f64>,
#[serde(default)]
pub max: Option<f64>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NodeDefinition {
pub node_type: String,
pub label: String,
#[serde(default)]
pub icon: String,
#[serde(default)]
pub header_color: String,
#[serde(default)]
pub category: String,
#[serde(default)]
pub fields: Vec<FieldDef>,
#[serde(default)]
pub inputs: Vec<Port>,
#[serde(default)]
pub outputs: Vec<Port>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum JobStatus {
Queued,
Running,
Success,
Failure,
Skipped,
Cancelled,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Job {
pub id: String,
pub name: String,
pub status: JobStatus,
pub command: String,
pub duration_secs: Option<u64>,
#[serde(default)]
pub started_at: Option<f64>,
pub depends_on: Vec<String>,
pub output: Option<String>,
#[serde(default)]
pub required_labels: Vec<String>,
#[serde(default)]
pub max_retries: u32,
#[serde(default)]
pub attempt: u32,
#[serde(default)]
pub metadata: HashMap<String, serde_json::Value>,
#[serde(default)]
pub ports: Vec<Port>,
#[serde(default)]
pub children: Option<Vec<Job>>,
#[serde(default)]
pub collapsed: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Workflow {
pub id: String,
pub name: String,
pub trigger: String,
pub jobs: Vec<Job>,
}
impl Workflow {
pub fn sample() -> Self {
Workflow {
id: "ci-1".into(),
name: "ci.yml".into(),
trigger: "on: push".into(),
jobs: vec![
Job {
id: "unit-tests".into(),
name: "Unit Tests".into(),
status: JobStatus::Queued,
command: "echo 'Running unit tests' && sleep 2".into(),
duration_secs: None,
depends_on: vec![],
started_at: None,
output: None,
required_labels: vec![],
max_retries: 0,
attempt: 0,
metadata: HashMap::new(),
ports: vec![],
children: None,
collapsed: false,
},
Job {
id: "lint".into(),
name: "Lint".into(),
status: JobStatus::Queued,
command: "echo 'Running linter' && sleep 1".into(),
duration_secs: None,
depends_on: vec![],
started_at: None,
output: None,
required_labels: vec![],
max_retries: 0,
attempt: 0,
metadata: HashMap::new(),
ports: vec![],
children: None,
collapsed: false,
},
Job {
id: "typecheck".into(),
name: "Typecheck".into(),
status: JobStatus::Queued,
command: "echo 'Running typecheck' && sleep 2".into(),
duration_secs: None,
depends_on: vec![],
started_at: None,
output: None,
required_labels: vec![],
max_retries: 0,
attempt: 0,
metadata: HashMap::new(),
ports: vec![],
children: None,
collapsed: false,
},
Job {
id: "build".into(),
name: "Build".into(),
status: JobStatus::Queued,
command: "echo 'Building project' && sleep 3".into(),
duration_secs: None,
depends_on: vec!["unit-tests".into(), "lint".into(), "typecheck".into()],
started_at: None,
output: None,
required_labels: vec![],
max_retries: 0,
attempt: 0,
metadata: HashMap::new(),
ports: vec![],
children: None,
collapsed: false,
},
Job {
id: "deploy-db".into(),
name: "Deploy DB Migrations".into(),
status: JobStatus::Queued,
command: "echo 'Deploying DB migrations' && sleep 1".into(),
duration_secs: None,
depends_on: vec!["build".into()],
started_at: None,
output: None,
required_labels: vec![],
max_retries: 0,
attempt: 0,
metadata: HashMap::new(),
ports: vec![],
children: None,
collapsed: false,
},
Job {
id: "e2e-tests".into(),
name: "E2E Tests".into(),
status: JobStatus::Queued,
command: "echo 'Running E2E tests' && sleep 5".into(),
duration_secs: None,
depends_on: vec!["build".into()],
started_at: None,
output: None,
required_labels: vec![],
max_retries: 0,
attempt: 0,
metadata: HashMap::new(),
ports: vec![],
children: None,
collapsed: false,
},
Job {
id: "deploy-preview".into(),
name: "Deploy Preview".into(),
status: JobStatus::Queued,
command: "echo 'Deploying preview' && sleep 1".into(),
duration_secs: None,
depends_on: vec!["build".into()],
started_at: None,
output: None,
required_labels: vec![],
max_retries: 0,
attempt: 0,
metadata: HashMap::new(),
ports: vec![],
children: None,
collapsed: false,
},
Job {
id: "deploy-web".into(),
name: "Deploy Web".into(),
status: JobStatus::Queued,
command: "echo 'Deploying to production' && sleep 3".into(),
duration_secs: None,
depends_on: vec!["deploy-db".into()],
started_at: None,
output: None,
required_labels: vec![],
max_retries: 0,
attempt: 0,
metadata: HashMap::new(),
ports: vec![],
children: None,
collapsed: false,
},
],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_job(id: &str, metadata: HashMap<String, serde_json::Value>) -> Job {
Job {
id: id.into(),
name: id.into(),
status: JobStatus::Queued,
command: "echo test".into(),
duration_secs: None,
started_at: None,
depends_on: vec![],
output: None,
required_labels: vec![],
max_retries: 0,
attempt: 0,
metadata,
ports: vec![],
children: None,
collapsed: false,
}
}
#[test]
fn job_metadata_serializes_roundtrip() {
let mut meta = HashMap::new();
meta.insert("node_type".into(), serde_json::json!("deploy"));
meta.insert("icon".into(), serde_json::json!("rocket"));
meta.insert("priority".into(), serde_json::json!(42));
let job = make_job("deploy-1", meta);
let json = serde_json::to_string(&job).unwrap();
let deserialized: Job = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.metadata.len(), 3);
assert_eq!(
deserialized.metadata["node_type"],
serde_json::json!("deploy")
);
assert_eq!(deserialized.metadata["icon"], serde_json::json!("rocket"));
assert_eq!(deserialized.metadata["priority"], serde_json::json!(42));
}
#[test]
fn job_metadata_defaults_to_empty() {
let json = r#"{
"id": "test",
"name": "Test",
"status": "queued",
"command": "echo hi",
"depends_on": []
}"#;
let job: Job = serde_json::from_str(json).unwrap();
assert!(job.metadata.is_empty());
}
#[test]
fn job_metadata_with_nested_values() {
let mut meta = HashMap::new();
meta.insert(
"config".into(),
serde_json::json!({"timeout": 30, "retries": true}),
);
meta.insert("tags".into(), serde_json::json!(["ci", "deploy"]));
let job = make_job("complex", meta);
let json = serde_json::to_string(&job).unwrap();
let deserialized: Job = serde_json::from_str(&json).unwrap();
assert_eq!(
deserialized.metadata["config"],
serde_json::json!({"timeout": 30, "retries": true})
);
assert_eq!(
deserialized.metadata["tags"],
serde_json::json!(["ci", "deploy"])
);
}
#[test]
fn job_metadata_from_json_string() {
let json = r##"{
"id": "styled",
"name": "Styled Node",
"status": "running",
"command": "echo hi",
"depends_on": [],
"metadata": {
"color": "#ff0000",
"weight": 1.5,
"visible": true
}
}"##;
let job: Job = serde_json::from_str(json).unwrap();
assert_eq!(job.metadata.len(), 3);
assert_eq!(job.metadata["color"], serde_json::json!("#ff0000"));
assert_eq!(job.metadata["weight"], serde_json::json!(1.5));
assert_eq!(job.metadata["visible"], serde_json::json!(true));
}
#[test]
fn workflow_sample_has_empty_metadata() {
let wf = Workflow::sample();
for job in &wf.jobs {
assert!(
job.metadata.is_empty(),
"Expected empty metadata for job '{}'",
job.id
);
}
}
}