swink_agent_patterns/pipeline/
types.rs1use std::fmt;
4
5use regex::Regex;
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub struct PipelineId(String);
14
15impl PipelineId {
16 pub fn new(id: impl Into<String>) -> Self {
18 Self(id.into())
19 }
20
21 pub fn generate() -> Self {
23 Self(Uuid::new_v4().to_string())
24 }
25}
26
27impl fmt::Display for PipelineId {
28 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29 f.write_str(&self.0)
30 }
31}
32
33#[non_exhaustive]
37#[derive(Clone, Debug, Serialize, Deserialize)]
38pub enum MergeStrategy {
39 Concat { separator: String },
41 First,
43 Fastest { n: usize },
45 Custom { aggregator: String },
47}
48
49#[non_exhaustive]
53#[derive(Clone, Debug)]
54pub enum ExitCondition {
55 ToolCalled { tool_name: String },
57 OutputContains {
59 pattern: String,
60 #[allow(dead_code)]
61 compiled: Regex,
62 },
63 MaxIterations,
65}
66
67impl ExitCondition {
68 pub fn output_contains(pattern: impl Into<String>) -> Result<Self, String> {
72 let pattern = pattern.into();
73 let compiled =
74 Regex::new(&pattern).map_err(|e| format!("invalid regex '{pattern}': {e}"))?;
75 Ok(Self::OutputContains { pattern, compiled })
76 }
77}
78
79impl Serialize for ExitCondition {
80 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
81 #[derive(Serialize)]
82 #[serde(tag = "type")]
83 enum Helper<'a> {
84 ToolCalled { tool_name: &'a str },
85 OutputContains { pattern: &'a str },
86 MaxIterations,
87 }
88
89 match self {
90 Self::ToolCalled { tool_name } => {
91 Helper::ToolCalled { tool_name }.serialize(serializer)
92 }
93 Self::OutputContains { pattern, .. } => {
94 Helper::OutputContains { pattern }.serialize(serializer)
95 }
96 Self::MaxIterations => Helper::MaxIterations.serialize(serializer),
97 }
98 }
99}
100
101impl<'de> Deserialize<'de> for ExitCondition {
102 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
103 #[derive(Deserialize)]
104 #[serde(tag = "type")]
105 enum Helper {
106 ToolCalled { tool_name: String },
107 OutputContains { pattern: String },
108 MaxIterations,
109 }
110
111 let h = Helper::deserialize(deserializer)?;
112 match h {
113 Helper::ToolCalled { tool_name } => Ok(Self::ToolCalled { tool_name }),
114 Helper::OutputContains { pattern } => {
115 let compiled = Regex::new(&pattern).map_err(serde::de::Error::custom)?;
116 Ok(Self::OutputContains { pattern, compiled })
117 }
118 Helper::MaxIterations => Ok(Self::MaxIterations),
119 }
120 }
121}
122
123#[non_exhaustive]
127#[derive(Clone, Debug, Serialize, Deserialize)]
128#[serde(tag = "type")]
129pub enum Pipeline {
130 Sequential {
132 id: PipelineId,
133 name: String,
134 steps: Vec<String>,
135 pass_context: bool,
136 },
137 Parallel {
139 id: PipelineId,
140 name: String,
141 branches: Vec<String>,
142 merge_strategy: MergeStrategy,
143 },
144 Loop {
146 id: PipelineId,
147 name: String,
148 body: String,
149 exit_condition: ExitCondition,
150 max_iterations: usize,
151 },
152}
153
154impl Pipeline {
155 pub fn sequential(name: impl Into<String>, steps: Vec<String>) -> Self {
157 Self::Sequential {
158 id: PipelineId::generate(),
159 name: name.into(),
160 steps,
161 pass_context: false,
162 }
163 }
164
165 pub fn sequential_with_context(name: impl Into<String>, steps: Vec<String>) -> Self {
167 Self::Sequential {
168 id: PipelineId::generate(),
169 name: name.into(),
170 steps,
171 pass_context: true,
172 }
173 }
174
175 pub fn parallel(
177 name: impl Into<String>,
178 branches: Vec<String>,
179 merge_strategy: MergeStrategy,
180 ) -> Self {
181 Self::Parallel {
182 id: PipelineId::generate(),
183 name: name.into(),
184 branches,
185 merge_strategy,
186 }
187 }
188
189 pub fn loop_(
191 name: impl Into<String>,
192 body: impl Into<String>,
193 exit_condition: ExitCondition,
194 ) -> Self {
195 Self::Loop {
196 id: PipelineId::generate(),
197 name: name.into(),
198 body: body.into(),
199 exit_condition,
200 max_iterations: 10,
201 }
202 }
203
204 pub fn loop_with_max(
206 name: impl Into<String>,
207 body: impl Into<String>,
208 exit_condition: ExitCondition,
209 max_iterations: usize,
210 ) -> Self {
211 Self::Loop {
212 id: PipelineId::generate(),
213 name: name.into(),
214 body: body.into(),
215 exit_condition,
216 max_iterations,
217 }
218 }
219
220 #[must_use]
222 pub fn with_id(mut self, id: PipelineId) -> Self {
223 match &mut self {
224 Self::Sequential { id: i, .. }
225 | Self::Parallel { id: i, .. }
226 | Self::Loop { id: i, .. } => *i = id,
227 }
228 self
229 }
230
231 pub fn id(&self) -> &PipelineId {
233 match self {
234 Self::Sequential { id, .. } | Self::Parallel { id, .. } | Self::Loop { id, .. } => id,
235 }
236 }
237
238 pub fn name(&self) -> &str {
240 match self {
241 Self::Sequential { name, .. }
242 | Self::Parallel { name, .. }
243 | Self::Loop { name, .. } => name,
244 }
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use std::collections::HashSet;
252
253 #[test]
256 fn pipeline_id_new_and_display() {
257 let id = PipelineId::new("test-pipeline");
258 assert_eq!(id.to_string(), "test-pipeline");
259 }
260
261 #[test]
262 fn pipeline_id_generate_is_unique() {
263 let a = PipelineId::generate();
264 let b = PipelineId::generate();
265 assert_ne!(a, b);
266 }
267
268 #[test]
269 fn pipeline_id_equality_and_hashing() {
270 let a = PipelineId::new("same");
271 let b = PipelineId::new("same");
272 assert_eq!(a, b);
273
274 let mut set = HashSet::new();
275 set.insert(a);
276 assert!(set.contains(&b));
277 }
278
279 #[test]
280 fn pipeline_id_serde_roundtrip() {
281 let id = PipelineId::new("round-trip");
282 let json = serde_json::to_string(&id).unwrap();
283 let parsed: PipelineId = serde_json::from_str(&json).unwrap();
284 assert_eq!(id, parsed);
285 }
286
287 #[test]
290 fn exit_condition_output_contains_valid_regex() {
291 let cond = ExitCondition::output_contains(r"\bDONE\b").unwrap();
292 match cond {
293 ExitCondition::OutputContains { pattern, compiled } => {
294 assert_eq!(pattern, r"\bDONE\b");
295 assert!(compiled.is_match("task DONE here"));
296 }
297 _ => panic!("expected OutputContains"),
298 }
299 }
300
301 #[test]
302 fn exit_condition_output_contains_invalid_regex() {
303 let result = ExitCondition::output_contains("[invalid");
304 assert!(result.is_err());
305 }
306
307 #[test]
308 fn exit_condition_serde_roundtrip_recompiles() {
309 let cond = ExitCondition::output_contains(r"done|finished").unwrap();
310 let json = serde_json::to_string(&cond).unwrap();
311 let parsed: ExitCondition = serde_json::from_str(&json).unwrap();
312 match parsed {
313 ExitCondition::OutputContains { pattern, compiled } => {
314 assert_eq!(pattern, "done|finished");
315 assert!(compiled.is_match("all done"));
316 }
317 _ => panic!("expected OutputContains"),
318 }
319 }
320
321 #[test]
324 fn sequential_constructor() {
325 let p = Pipeline::sequential("test", vec!["a".into(), "b".into()]);
326 assert_eq!(p.name(), "test");
327 match &p {
328 Pipeline::Sequential {
329 pass_context,
330 steps,
331 ..
332 } => {
333 assert!(!pass_context);
334 assert_eq!(steps.len(), 2);
335 }
336 _ => panic!("expected Sequential"),
337 }
338 }
339
340 #[test]
341 fn parallel_constructor() {
342 let p = Pipeline::parallel("par", vec!["x".into(), "y".into()], MergeStrategy::First);
343 assert_eq!(p.name(), "par");
344 assert!(matches!(p, Pipeline::Parallel { .. }));
345 }
346
347 #[test]
348 fn loop_constructor() {
349 let p = Pipeline::loop_("lp", "body-agent", ExitCondition::MaxIterations);
350 assert_eq!(p.name(), "lp");
351 match &p {
352 Pipeline::Loop { max_iterations, .. } => assert_eq!(*max_iterations, 10),
353 _ => panic!("expected Loop"),
354 }
355 }
356
357 #[test]
358 fn with_id_overrides_generated_id() {
359 let custom = PipelineId::new("custom-id");
360 let p = Pipeline::sequential("s", vec![]).with_id(custom.clone());
361 assert_eq!(*p.id(), custom);
362 }
363
364 #[test]
365 fn auto_generated_ids_are_unique() {
366 let a = Pipeline::sequential("a", vec![]);
367 let b = Pipeline::sequential("b", vec![]);
368 assert_ne!(a.id(), b.id());
369 }
370}