Skip to main content

metalcraft_flows/
model.rs

1//! Core data model for the Flow specification.
2//!
3//! See [`SPEC.md`](https://github.com/rust4ai/metalcraft-flows/blob/main/SPEC.md)
4//! for the formal wire format.
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8/// The current spec version this crate emits.
9///
10/// Documents without a `spec_version` field are parsed as version `"1"`.
11pub const SPEC_VERSION: &str = "1";
12
13/// A single vertex in a [`FlowDefinition`].
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct FlowNode {
16    /// Unique identifier within the enclosing [`FlowDefinition`].
17    pub id: String,
18    /// The node kind. See [`FlowNodeType`].
19    pub node_type: FlowNodeType,
20    /// Free-form per-node configuration. Schema depends on `node_type`.
21    pub data: serde_json::Value,
22    /// `[x, y]` coordinates for visual editors. Defaults to `[0.0, 0.0]`.
23    #[serde(default)]
24    pub position: [f64; 2],
25}
26
27/// A node's kind.
28///
29/// Core types are spec-defined and understood by all conformant runtimes.
30/// Custom types are vendor-namespaced (`vendor:name`) and opaque to the spec —
31/// runtimes preserve them but may refuse to execute unknown ones.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum FlowNodeType {
34    /// A spec-defined core node type.
35    Core(CoreNodeType),
36    /// A vendor-namespaced custom node type, e.g. `"slack:send_message"`.
37    ///
38    /// The string is preserved verbatim, including the vendor prefix.
39    Custom(String),
40}
41
42/// The closed set of core node types defined by the spec.
43///
44/// See [`SPEC.md` §5.1](https://github.com/rust4ai/metalcraft-flows/blob/main/SPEC.md).
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46pub enum CoreNodeType {
47    /// Marks the flow's start. At most one per [`FlowDefinition`].
48    Entry,
49    /// A natural-language instruction to be passed to an LLM agent.
50    Prompt,
51    /// Splits flow execution based on a condition.
52    Branch,
53    /// Branches based on the outcome of a tool call.
54    BranchTool,
55}
56
57impl CoreNodeType {
58    /// The wire-format string for this core node type.
59    pub fn as_str(self) -> &'static str {
60        match self {
61            CoreNodeType::Entry => "entry",
62            CoreNodeType::Prompt => "prompt",
63            CoreNodeType::Branch => "branch",
64            CoreNodeType::BranchTool => "branch_tool",
65        }
66    }
67
68    /// Parse a wire-format string into a core node type, if it matches one.
69    pub fn from_wire(s: &str) -> Option<Self> {
70        match s {
71            "entry" => Some(CoreNodeType::Entry),
72            "prompt" => Some(CoreNodeType::Prompt),
73            "branch" => Some(CoreNodeType::Branch),
74            "branch_tool" => Some(CoreNodeType::BranchTool),
75            _ => None,
76        }
77    }
78}
79
80impl FlowNodeType {
81    /// The wire-format string for this node type.
82    pub fn as_wire(&self) -> &str {
83        match self {
84            FlowNodeType::Core(c) => c.as_str(),
85            FlowNodeType::Custom(s) => s.as_str(),
86        }
87    }
88}
89
90impl Serialize for FlowNodeType {
91    fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
92        ser.serialize_str(self.as_wire())
93    }
94}
95
96impl<'de> Deserialize<'de> for FlowNodeType {
97    fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
98        let s = String::deserialize(de)?;
99        if let Some(core) = CoreNodeType::from_wire(&s) {
100            Ok(FlowNodeType::Core(core))
101        } else {
102            Ok(FlowNodeType::Custom(s))
103        }
104    }
105}
106
107/// A directed arc connecting two nodes in a [`FlowDefinition`].
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
109pub struct FlowEdge {
110    /// Unique identifier within the enclosing [`FlowDefinition`].
111    pub id: String,
112    /// The id of the source [`FlowNode`].
113    pub source: String,
114    /// The id of the target [`FlowNode`].
115    pub target: String,
116    /// Optional named output port on the source node (multi-output nodes
117    /// like [`CoreNodeType::Branch`]).
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub source_handle: Option<String>,
120    /// Optional named input port on the target node.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub target_handle: Option<String>,
123}
124
125/// A graph: nodes and the directed edges between them.
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
127pub struct FlowDefinition {
128    /// All vertices in the graph.
129    pub nodes: Vec<FlowNode>,
130    /// All directed arcs in the graph.
131    pub edges: Vec<FlowEdge>,
132}
133
134/// A persisted flow document — what a `.json` file on disk contains.
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136pub struct SavedFlow {
137    /// Spec version this document conforms to. Defaults to `"1"` when absent.
138    #[serde(default = "default_spec_version")]
139    pub spec_version: String,
140    /// Stable identifier. Must match `^[A-Za-z0-9-]{1,64}$`.
141    pub id: String,
142    /// Human-readable label.
143    pub name: String,
144    /// ISO-8601 / RFC-3339 creation timestamp.
145    pub created_at: String,
146    /// ISO-8601 / RFC-3339 last-modified timestamp.
147    pub updated_at: String,
148    /// Whether the flow should be executed by a scheduler. Defaults to `false`.
149    #[serde(default)]
150    pub enabled: bool,
151    /// The graph definition.
152    pub flow: FlowDefinition,
153}
154
155fn default_spec_version() -> String {
156    SPEC_VERSION.to_string()
157}
158
159/// Lightweight metadata describing a saved flow, without the graph payload.
160///
161/// Returned by directory listings — see [`crate::store::list_flows`].
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
163pub struct FlowSummary {
164    /// The flow's stable identifier.
165    pub id: String,
166    /// Human-readable label.
167    pub name: String,
168    /// Number of nodes in the graph.
169    pub node_count: usize,
170    /// ISO-8601 / RFC-3339 creation timestamp.
171    pub created_at: String,
172    /// ISO-8601 / RFC-3339 last-modified timestamp.
173    pub updated_at: String,
174    /// Whether the flow is enabled for scheduling.
175    #[serde(default)]
176    pub enabled: bool,
177}
178
179/// Whether an id is safe to use as a filename per [`SPEC.md` §1.1].
180pub(crate) fn is_safe_id(id: &str) -> bool {
181    !id.is_empty()
182        && id.len() <= 64
183        && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
184}
185
186/// Whether a vendor namespace conforms to the rules in [`SPEC.md` §5.2].
187pub(crate) fn is_valid_vendor(prefix: &str) -> bool {
188    let mut chars = prefix.chars();
189    let Some(first) = chars.next() else { return false };
190    if !first.is_ascii_lowercase() {
191        return false;
192    }
193    if prefix.len() > 32 {
194        return false;
195    }
196    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use serde_json::json;
203
204    #[test]
205    fn core_node_type_round_trips() {
206        for ct in [
207            CoreNodeType::Entry,
208            CoreNodeType::Prompt,
209            CoreNodeType::Branch,
210            CoreNodeType::BranchTool,
211        ] {
212            let nt = FlowNodeType::Core(ct);
213            let j = serde_json::to_string(&nt).unwrap();
214            let back: FlowNodeType = serde_json::from_str(&j).unwrap();
215            assert_eq!(nt, back);
216        }
217    }
218
219    #[test]
220    fn custom_node_type_round_trips() {
221        let nt = FlowNodeType::Custom("slack:send_message".to_string());
222        let j = serde_json::to_string(&nt).unwrap();
223        assert_eq!(j, "\"slack:send_message\"");
224        let back: FlowNodeType = serde_json::from_str(&j).unwrap();
225        assert_eq!(nt, back);
226    }
227
228    #[test]
229    fn unknown_bare_node_type_becomes_custom() {
230        let back: FlowNodeType = serde_json::from_str("\"future_core_type\"").unwrap();
231        assert_eq!(back, FlowNodeType::Custom("future_core_type".into()));
232    }
233
234    #[test]
235    fn missing_spec_version_defaults_to_v1() {
236        let doc = json!({
237            "id": "x",
238            "name": "X",
239            "created_at": "2026-01-01T00:00:00Z",
240            "updated_at": "2026-01-01T00:00:00Z",
241            "flow": { "nodes": [], "edges": [] }
242        });
243        let parsed: SavedFlow = serde_json::from_value(doc).unwrap();
244        assert_eq!(parsed.spec_version, "1");
245        assert!(!parsed.enabled);
246    }
247
248    #[test]
249    fn saved_flow_round_trips() {
250        let sf = SavedFlow {
251            spec_version: "1".into(),
252            id: "f1".into(),
253            name: "F1".into(),
254            created_at: "2026-01-01T00:00:00Z".into(),
255            updated_at: "2026-01-02T00:00:00Z".into(),
256            enabled: true,
257            flow: FlowDefinition {
258                nodes: vec![FlowNode {
259                    id: "n1".into(),
260                    node_type: FlowNodeType::Core(CoreNodeType::Entry),
261                    data: json!({"schedule_type": "manual"}),
262                    position: [10.0, 20.0],
263                }],
264                edges: vec![],
265            },
266        };
267        let j = serde_json::to_string(&sf).unwrap();
268        let back: SavedFlow = serde_json::from_str(&j).unwrap();
269        assert_eq!(sf, back);
270    }
271
272    #[test]
273    fn id_validation() {
274        assert!(is_safe_id("ok-id"));
275        assert!(is_safe_id("a"));
276        assert!(!is_safe_id(""));
277        assert!(!is_safe_id("has space"));
278        assert!(!is_safe_id("../escape"));
279        assert!(!is_safe_id(&"x".repeat(65)));
280    }
281
282    #[test]
283    fn vendor_validation() {
284        assert!(is_valid_vendor("slack"));
285        assert!(is_valid_vendor("my-co"));
286        assert!(is_valid_vendor("my_co"));
287        assert!(is_valid_vendor("co0"));
288        assert!(!is_valid_vendor(""));
289        assert!(!is_valid_vendor("0starts-with-digit"));
290        assert!(!is_valid_vendor("Capital"));
291        assert!(!is_valid_vendor(&"a".repeat(33)));
292    }
293}