Skip to main content

zeph_core/pipeline/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4pub mod builder;
5pub mod builtin;
6pub mod parallel;
7pub mod step;
8
9pub use builder::Pipeline;
10pub use parallel::ParallelStep;
11pub use step::Step;
12
13#[non_exhaustive]
14#[derive(Debug, thiserror::Error)]
15pub enum PipelineError {
16    #[error(transparent)]
17    Llm(#[from] zeph_llm::LlmError),
18
19    #[error(transparent)]
20    Memory(#[from] zeph_memory::MemoryError),
21
22    #[error("extraction failed: {0}")]
23    Extract(String),
24
25    #[error("{0}")]
26    Custom(String),
27}
28
29#[cfg(test)]
30mod tests {
31    #![allow(clippy::ignored_unit_patterns, clippy::manual_string_new)]
32    use std::assert_matches;
33
34    use std::sync::Arc;
35
36    use super::builtin::{ExtractStep, LlmStep, MapStep, RetrievalStep};
37    use super::parallel::parallel;
38    use super::*;
39    use zeph_llm::mock::MockProvider;
40    use zeph_memory::in_memory_store::InMemoryVectorStore;
41    use zeph_memory::vector_store::{VectorPoint, VectorStore};
42
43    struct AddSuffix {
44        suffix: String,
45    }
46
47    impl Step for AddSuffix {
48        type Input = String;
49        type Output = String;
50
51        async fn run(&self, input: Self::Input) -> Result<Self::Output, PipelineError> {
52            Ok(format!("{input}{}", self.suffix))
53        }
54    }
55
56    struct ParseLen;
57
58    impl Step for ParseLen {
59        type Input = String;
60        type Output = usize;
61
62        async fn run(&self, input: Self::Input) -> Result<Self::Output, PipelineError> {
63            Ok(input.len())
64        }
65    }
66
67    #[tokio::test]
68    async fn single_step_pipeline() {
69        let result = Pipeline::start(AddSuffix { suffix: "!".into() })
70            .run("hello".into())
71            .await
72            .unwrap();
73        assert_eq!(result, "hello!");
74    }
75
76    #[tokio::test]
77    async fn chained_pipeline() {
78        let result = Pipeline::start(AddSuffix {
79            suffix: " world".into(),
80        })
81        .step(AddSuffix { suffix: "!".into() })
82        .run("hello".into())
83        .await
84        .unwrap();
85        assert_eq!(result, "hello world!");
86    }
87
88    #[tokio::test]
89    async fn heterogeneous_chain() {
90        let result = Pipeline::start(AddSuffix {
91            suffix: "abc".into(),
92        })
93        .step(ParseLen)
94        .run(String::new())
95        .await
96        .unwrap();
97        assert_eq!(result, 3);
98    }
99
100    #[tokio::test]
101    async fn map_step() {
102        let result = Pipeline::start(MapStep::new(|s: String| s.to_uppercase()))
103            .run("hello".into())
104            .await
105            .unwrap();
106        assert_eq!(result, "HELLO");
107    }
108
109    #[tokio::test]
110    async fn parallel_step() {
111        let step = parallel(
112            AddSuffix {
113                suffix: "_a".into(),
114            },
115            AddSuffix {
116                suffix: "_b".into(),
117            },
118        );
119        let result = Pipeline::start(step).run("x".into()).await.unwrap();
120        assert_eq!(result, ("x_a".into(), "x_b".into()));
121    }
122
123    #[tokio::test]
124    async fn error_propagation() {
125        struct FailStep;
126
127        impl Step for FailStep {
128            type Input = String;
129            type Output = String;
130
131            async fn run(&self, _input: Self::Input) -> Result<Self::Output, PipelineError> {
132                Err(PipelineError::Custom("boom".into()))
133            }
134        }
135
136        let result = Pipeline::start(AddSuffix {
137            suffix: "ok".into(),
138        })
139        .step(FailStep)
140        .run("hi".into())
141        .await;
142        assert!(result.is_err());
143        assert!(result.unwrap_err().to_string().contains("boom"));
144    }
145
146    #[tokio::test]
147    async fn extract_step() {
148        use super::builtin::ExtractStep;
149
150        let result = Pipeline::start(MapStep::new(|(): ()| r#"{"a":1,"b":"two"}"#.to_owned()))
151            .step(ExtractStep::<serde_json::Value>::new())
152            .run(())
153            .await
154            .unwrap();
155        assert_eq!(result["a"], 1);
156        assert_eq!(result["b"], "two");
157    }
158
159    // --- LlmStep tests ---
160
161    #[tokio::test]
162    async fn llm_step_returns_response() {
163        let provider = Arc::new(MockProvider::with_responses(vec!["answer".into()]));
164        let result = Pipeline::start(LlmStep::new(provider))
165            .run("question".into())
166            .await
167            .unwrap();
168        assert_eq!(result, "answer");
169    }
170
171    #[tokio::test]
172    async fn llm_step_with_system_prompt() {
173        let provider = Arc::new(MockProvider::with_responses(vec!["ok".into()]));
174        let result = Pipeline::start(LlmStep::new(provider).with_system_prompt("sys"))
175            .run("input".into())
176            .await
177            .unwrap();
178        assert_eq!(result, "ok");
179    }
180
181    #[tokio::test]
182    async fn llm_step_propagates_error() {
183        let provider = Arc::new(MockProvider::failing());
184        let result = Pipeline::start(LlmStep::new(provider))
185            .run("input".into())
186            .await;
187        assert!(result.is_err());
188        assert!(
189            matches!(result.unwrap_err(), PipelineError::Llm(_)),
190            "expected PipelineError::Llm"
191        );
192    }
193
194    // --- RetrievalStep tests ---
195
196    #[tokio::test]
197    async fn retrieval_step_returns_results() {
198        let store = Arc::new(InMemoryVectorStore::new());
199        store.ensure_collection("col", 3).await.unwrap();
200        store
201            .upsert(
202                "col",
203                vec![VectorPoint {
204                    id: "p1".into(),
205                    vector: vec![1.0, 0.0, 0.0],
206                    payload: std::collections::HashMap::new(),
207                }],
208            )
209            .await
210            .unwrap();
211
212        let mut provider = MockProvider::default();
213        provider.supports_embeddings = true;
214        provider.embedding = vec![1.0, 0.0, 0.0];
215        let provider = Arc::new(provider);
216
217        let step = RetrievalStep::new(store, provider, "col", 5);
218        let results = Pipeline::start(step).run("query".into()).await.unwrap();
219        assert_eq!(results.len(), 1);
220        assert_eq!(results[0].id, "p1");
221    }
222
223    #[tokio::test]
224    async fn retrieval_step_embed_error_propagates() {
225        let store = Arc::new(InMemoryVectorStore::new());
226        store.ensure_collection("col", 3).await.unwrap();
227
228        let provider = Arc::new(MockProvider::default());
229
230        let step = RetrievalStep::new(store, provider, "col", 5);
231        let result = Pipeline::start(step).run("query".into()).await;
232        assert_matches!(result.unwrap_err(), PipelineError::Llm(_));
233    }
234
235    // --- ExtractStep failure tests ---
236
237    #[tokio::test]
238    async fn extract_step_invalid_json() {
239        let result = Pipeline::start(MapStep::new(|(): ()| "not json".to_owned()))
240            .step(ExtractStep::<serde_json::Value>::new())
241            .run(())
242            .await;
243        assert_matches!(result.unwrap_err(), PipelineError::Extract(_));
244    }
245
246    #[tokio::test]
247    async fn extract_step_type_mismatch() {
248        #[derive(Debug, serde::Deserialize)]
249        struct Strict {
250            #[expect(dead_code)]
251            required_field: Vec<u32>,
252        }
253
254        let result = Pipeline::start(MapStep::new(|(): ()| r#"{"a":1}"#.to_owned()))
255            .step(ExtractStep::<Strict>::new())
256            .run(())
257            .await;
258        assert_matches!(result.unwrap_err(), PipelineError::Extract(_));
259    }
260
261    // --- ParallelStep error tests ---
262
263    #[tokio::test]
264    async fn parallel_step_first_fails() {
265        struct FailStep;
266        impl Step for FailStep {
267            type Input = String;
268            type Output = String;
269            async fn run(&self, _input: Self::Input) -> Result<Self::Output, PipelineError> {
270                Err(PipelineError::Custom("fail_a".into()))
271            }
272        }
273
274        let step = parallel(
275            FailStep,
276            AddSuffix {
277                suffix: "_ok".into(),
278            },
279        );
280        let result = Pipeline::start(step).run("x".into()).await;
281        assert!(result.is_err());
282    }
283
284    #[tokio::test]
285    async fn parallel_step_both_fail() {
286        struct FailA;
287        impl Step for FailA {
288            type Input = String;
289            type Output = String;
290            async fn run(&self, _input: Self::Input) -> Result<Self::Output, PipelineError> {
291                Err(PipelineError::Custom("fail_a".into()))
292            }
293        }
294        struct FailB;
295        impl Step for FailB {
296            type Input = String;
297            type Output = String;
298            async fn run(&self, _input: Self::Input) -> Result<Self::Output, PipelineError> {
299                Err(PipelineError::Custom("fail_b".into()))
300            }
301        }
302
303        let step = parallel(FailA, FailB);
304        let result = Pipeline::start(step).run("x".into()).await;
305        assert!(result.is_err());
306    }
307}