use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub const SPEC_VERSION: &str = "1";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FlowNode {
pub id: String,
pub node_type: FlowNodeType,
pub data: serde_json::Value,
#[serde(default)]
pub position: [f64; 2],
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FlowNodeType {
Core(CoreNodeType),
Custom(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CoreNodeType {
Entry,
Prompt,
Branch,
BranchTool,
}
impl CoreNodeType {
pub fn as_str(self) -> &'static str {
match self {
CoreNodeType::Entry => "entry",
CoreNodeType::Prompt => "prompt",
CoreNodeType::Branch => "branch",
CoreNodeType::BranchTool => "branch_tool",
}
}
pub fn from_wire(s: &str) -> Option<Self> {
match s {
"entry" => Some(CoreNodeType::Entry),
"prompt" => Some(CoreNodeType::Prompt),
"branch" => Some(CoreNodeType::Branch),
"branch_tool" => Some(CoreNodeType::BranchTool),
_ => None,
}
}
}
impl FlowNodeType {
pub fn as_wire(&self) -> &str {
match self {
FlowNodeType::Core(c) => c.as_str(),
FlowNodeType::Custom(s) => s.as_str(),
}
}
}
impl Serialize for FlowNodeType {
fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
ser.serialize_str(self.as_wire())
}
}
impl<'de> Deserialize<'de> for FlowNodeType {
fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
let s = String::deserialize(de)?;
if let Some(core) = CoreNodeType::from_wire(&s) {
Ok(FlowNodeType::Core(core))
} else {
Ok(FlowNodeType::Custom(s))
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FlowEdge {
pub id: String,
pub source: String,
pub target: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_handle: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_handle: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct FlowDefinition {
pub nodes: Vec<FlowNode>,
pub edges: Vec<FlowEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SavedFlow {
#[serde(default = "default_spec_version")]
pub spec_version: String,
pub id: String,
pub name: String,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub enabled: bool,
pub flow: FlowDefinition,
}
fn default_spec_version() -> String {
SPEC_VERSION.to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FlowSummary {
pub id: String,
pub name: String,
pub node_count: usize,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub enabled: bool,
}
pub(crate) fn is_safe_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
pub(crate) fn is_valid_vendor(prefix: &str) -> bool {
let mut chars = prefix.chars();
let Some(first) = chars.next() else { return false };
if !first.is_ascii_lowercase() {
return false;
}
if prefix.len() > 32 {
return false;
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn core_node_type_round_trips() {
for ct in [
CoreNodeType::Entry,
CoreNodeType::Prompt,
CoreNodeType::Branch,
CoreNodeType::BranchTool,
] {
let nt = FlowNodeType::Core(ct);
let j = serde_json::to_string(&nt).unwrap();
let back: FlowNodeType = serde_json::from_str(&j).unwrap();
assert_eq!(nt, back);
}
}
#[test]
fn custom_node_type_round_trips() {
let nt = FlowNodeType::Custom("slack:send_message".to_string());
let j = serde_json::to_string(&nt).unwrap();
assert_eq!(j, "\"slack:send_message\"");
let back: FlowNodeType = serde_json::from_str(&j).unwrap();
assert_eq!(nt, back);
}
#[test]
fn unknown_bare_node_type_becomes_custom() {
let back: FlowNodeType = serde_json::from_str("\"future_core_type\"").unwrap();
assert_eq!(back, FlowNodeType::Custom("future_core_type".into()));
}
#[test]
fn missing_spec_version_defaults_to_v1() {
let doc = json!({
"id": "x",
"name": "X",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"flow": { "nodes": [], "edges": [] }
});
let parsed: SavedFlow = serde_json::from_value(doc).unwrap();
assert_eq!(parsed.spec_version, "1");
assert!(!parsed.enabled);
}
#[test]
fn saved_flow_round_trips() {
let sf = SavedFlow {
spec_version: "1".into(),
id: "f1".into(),
name: "F1".into(),
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-02T00:00:00Z".into(),
enabled: true,
flow: FlowDefinition {
nodes: vec![FlowNode {
id: "n1".into(),
node_type: FlowNodeType::Core(CoreNodeType::Entry),
data: json!({"schedule_type": "manual"}),
position: [10.0, 20.0],
}],
edges: vec![],
},
};
let j = serde_json::to_string(&sf).unwrap();
let back: SavedFlow = serde_json::from_str(&j).unwrap();
assert_eq!(sf, back);
}
#[test]
fn id_validation() {
assert!(is_safe_id("ok-id"));
assert!(is_safe_id("a"));
assert!(!is_safe_id(""));
assert!(!is_safe_id("has space"));
assert!(!is_safe_id("../escape"));
assert!(!is_safe_id(&"x".repeat(65)));
}
#[test]
fn vendor_validation() {
assert!(is_valid_vendor("slack"));
assert!(is_valid_vendor("my-co"));
assert!(is_valid_vendor("my_co"));
assert!(is_valid_vendor("co0"));
assert!(!is_valid_vendor(""));
assert!(!is_valid_vendor("0starts-with-digit"));
assert!(!is_valid_vendor("Capital"));
assert!(!is_valid_vendor(&"a".repeat(33)));
}
}