Skip to main content

proxima_primitives/pipe/
ext.rs

1//! Fluent combinator sugar over the pipe algebra — [`PipeExt`], a single
2//! blanket, own-tier extension trait over the root [`Pipe`] (sugar only,
3//! never a capability — nothing gates on it) so `a.and_then(b)` reads
4//! left-to-right instead of `AndThen::new(a, b)`.
5//!
6//! One trait, not four: every pipe in this crate implements the base [`Pipe`]
7//! (the higher tiers — [`crate::pipe::primitives::SendPipe`], [`UnpinPipe`],
8//! [`crate::pipe::primitives::UnpinSendPipe`] — are
9//! ADDITIVE constraints on top, never a replacement for it), so a single
10//! `PipeExt: Pipe + Sized` blanket already reaches every pipe, and there is no
11//! second trait providing the same method names to be ambiguous against. The
12//! combinator VALUE `and_then`/`filter`/`fanout`/`fanin` build ([`AndThen`],
13//! [`FanOut`], [`FanIn`]) carries whatever higher tiers its own stages
14//! qualify for regardless of which trait constructed it — `SendPipe`/
15//! `UnpinPipe`/`UnpinSendPipe`-ness is a property of the concrete types
16//! involved, not of the call that built the value.
17//!
18//! `and_then` and `filter` are native: both build [`AndThen`] over two pipes.
19//! `filter` gates `self` behind a predicate — `predicate` runs first, and
20//! only an admitted item reaches `self` — matching the order
21//! `FilterConfig::into_filter` already composes in (`predicate.and_then(inner)`,
22//! see `pipe::filter`), just read fluently from the inner pipe's side instead
23//! of the predicate's.
24//!
25//! `fanout` is native too: [`FanOut`] now carries a base `Pipe` impl (`pipe::
26//! fanout`) alongside its original `SendPipe` one, so building it needs
27//! nothing beyond `Pipe` + `Clone`.
28//!
29//! `fanin` carries one extra bound: [`FanIn`] only exists over [`UnpinPipe`]
30//! sources shaped `In = (), Err = Exhausted` (`pipe::fan_in`) — that shape,
31//! not the tier itself, is what's extra; a `Pipe` that also happens to be
32//! such an `UnpinPipe` source can still call it.
33
34use crate::pipe::fan_in::{Exhausted, FanIn, FanInStrategy};
35use crate::pipe::primitives::{AndThen, Pipe, UnpinPipe};
36use proxima_core::markers::DropSafe;
37
38#[cfg(feature = "alloc")]
39use crate::pipe::fanout::{AllOrNothing, FanOut};
40#[cfg(feature = "alloc")]
41use alloc::vec;
42
43/// Fluent sugar over the root [`Pipe`]. See the module doc for what each
44/// method builds.
45pub trait PipeExt: Pipe + Sized {
46    /// `self.and_then(next)` is `AndThen::new(self, next)` — reads
47    /// left-to-right at the call site.
48    fn and_then<Next>(self, next: Next) -> AndThen<Self, Next>
49    where
50        Next: Pipe<In = Self::Out>,
51        Next::Err: From<Self::Err>,
52    {
53        AndThen::new(self, next)
54    }
55
56    /// Gate `self` behind `predicate`.
57    fn filter<Pred>(self, predicate: Pred) -> AndThen<Pred, Self>
58    where
59        Pred: Pipe<Out = Self::In>,
60        Self::Err: From<Pred::Err>,
61    {
62        AndThen::new(predicate, self)
63    }
64
65    /// Broadcast to `self` and `other`, all-or-nothing.
66    #[cfg(feature = "alloc")]
67    fn fanout(self, other: Self) -> FanOut<Self, AllOrNothing>
68    where
69        Self: Clone,
70        Self::In: Clone,
71    {
72        FanOut::new(vec![self, other])
73    }
74
75    /// Merge `self` and `other` into a two-source [`FanIn`]. `FanIn` sources
76    /// are `UnpinPipe`-shaped (`In = ()`, `Err = Exhausted`), hence the extra
77    /// bound.
78    fn fanin<Strategy>(self, other: Self, strategy: Strategy) -> FanIn<Self, Strategy, 2>
79    where
80        Self: UnpinPipe<In = (), Err = Exhausted> + DropSafe,
81        Strategy: FanInStrategy,
82    {
83        FanIn::new([self, other], strategy)
84    }
85}
86
87impl<P: Pipe> PipeExt for P {}
88
89#[cfg(test)]
90#[allow(clippy::unwrap_used, clippy::expect_used)]
91mod tests {
92    use super::{FanInStrategy, Pipe, PipeExt};
93    use crate::block_on;
94    use core::future::Future;
95
96    #[derive(Debug, PartialEq, Eq)]
97    struct Overflow;
98
99    struct Increment;
100    impl Pipe for Increment {
101        type In = u64;
102        type Out = u64;
103        type Err = Overflow;
104        fn call(&self, input: u64) -> impl Future<Output = Result<u64, Overflow>> {
105            async move { input.checked_add(1).ok_or(Overflow) }
106        }
107    }
108
109    #[derive(Debug, PartialEq, Eq)]
110    enum ChainError {
111        Overflowed,
112        Rejected,
113    }
114    impl From<Overflow> for ChainError {
115        fn from(_: Overflow) -> Self {
116            ChainError::Overflowed
117        }
118    }
119
120    struct Double;
121    impl Pipe for Double {
122        type In = u64;
123        type Out = u64;
124        type Err = ChainError;
125        fn call(&self, input: u64) -> impl Future<Output = Result<u64, ChainError>> {
126            async move { Ok(input * 2) }
127        }
128    }
129
130    // the predicate `.filter` gates the chain behind — rejects odd input
131    // before `Increment`/`Double` ever run.
132    struct RejectOdd;
133    impl Pipe for RejectOdd {
134        type In = u64;
135        type Out = u64;
136        type Err = ChainError;
137        fn call(&self, input: u64) -> impl Future<Output = Result<u64, ChainError>> {
138            async move {
139                if input.is_multiple_of(2) {
140                    Ok(input)
141                } else {
142                    Err(ChainError::Rejected)
143                }
144            }
145        }
146    }
147
148    #[test]
149    fn and_then_and_filter_compose_through_the_ext_sugar() {
150        // `use PipeExt` (via `use super::*` above) is what makes `.and_then`
151        // and `.filter` resolve here — no prelude in this crate.
152        let pipeline = Increment.and_then(Double).filter(RejectOdd);
153
154        let admitted = block_on(Pipe::call(&pipeline, 4));
155        assert_eq!(admitted, Ok(10), "4 -> RejectOdd passes -> +1 -> *2 -> 10");
156
157        let rejected = block_on(Pipe::call(&pipeline, 3));
158        assert_eq!(
159            rejected,
160            Err(ChainError::Rejected),
161            "3 is odd: RejectOdd stops the chain before Increment runs"
162        );
163    }
164
165    #[cfg(feature = "alloc")]
166    mod fanout_ext {
167        use super::{Pipe, PipeExt};
168        use alloc::sync::Arc;
169        use core::convert::Infallible;
170        use core::future::Future;
171        use core::sync::atomic::{AtomicUsize, Ordering};
172
173        #[derive(Clone)]
174        struct RecordingSink {
175            calls: Arc<AtomicUsize>,
176        }
177
178        impl Pipe for RecordingSink {
179            type In = u32;
180            type Out = ();
181            type Err = Infallible;
182            fn call(&self, _input: u32) -> impl Future<Output = Result<(), Infallible>> {
183                self.calls.fetch_add(1, Ordering::Relaxed);
184                async move { Ok(()) }
185            }
186        }
187
188        #[test]
189        fn fanout_ext_broadcasts_to_both_arms() {
190            let calls = Arc::new(AtomicUsize::new(0));
191            let fan = RecordingSink {
192                calls: Arc::clone(&calls),
193            }
194            .fanout(RecordingSink {
195                calls: Arc::clone(&calls),
196            });
197
198            let outcome = super::block_on(Pipe::call(&fan, 7));
199
200            assert_eq!(outcome, Ok(()));
201            assert_eq!(calls.load(Ordering::Relaxed), 2, "both arms ran");
202        }
203    }
204
205    struct OnceSource {
206        emitted: core::cell::Cell<bool>,
207        value: u32,
208    }
209    impl proxima_core::markers::DropSafe for OnceSource {}
210    impl super::UnpinPipe for OnceSource {
211        type In = ();
212        type Out = u32;
213        type Err = super::Exhausted;
214        fn call(&self, (): ()) -> impl Future<Output = Result<u32, super::Exhausted>> + Unpin {
215            if self.emitted.replace(true) {
216                core::future::ready(Err(super::Exhausted))
217            } else {
218                core::future::ready(Ok(self.value))
219            }
220        }
221    }
222    // base-tier mirror, delegating straight through — needed for `PipeExt`
223    // (the `.fanin` sugar) to reach `OnceSource` at all.
224    impl Pipe for OnceSource {
225        type In = ();
226        type Out = u32;
227        type Err = super::Exhausted;
228        fn call(&self, (): ()) -> impl Future<Output = Result<u32, super::Exhausted>> {
229            super::UnpinPipe::call(self, ())
230        }
231    }
232
233    #[test]
234    fn fanin_ext_merges_both_sources_via_the_sugar() {
235        let fan = (OnceSource {
236            emitted: core::cell::Cell::new(false),
237            value: 1,
238        })
239        .fanin(
240            OnceSource {
241                emitted: core::cell::Cell::new(false),
242                value: 2,
243            },
244            super::super::fan_in::Select::RoundRobin,
245        );
246
247        let waker = core::task::Waker::noop();
248        let mut context = core::task::Context::from_waker(waker);
249        let mut merged = alloc_free_collect(&fan, &mut context);
250        merged.sort_unstable();
251        assert_eq!(merged, [1, 2], "both sources drain through the fanin sugar");
252    }
253
254    // no-alloc drain: a fixed-size buffer, since `FanIn` itself is no-alloc.
255    fn alloc_free_collect<S, Strategy, const N: usize>(
256        fan: &super::FanIn<S, Strategy, N>,
257        cx: &mut core::task::Context<'_>,
258    ) -> [u32; N]
259    where
260        S: super::UnpinPipe<In = (), Out = u32, Err = super::Exhausted>
261            + proxima_core::markers::DropSafe,
262        Strategy: FanInStrategy,
263    {
264        let mut out = [0u32; N];
265        let mut count = 0;
266        for _ in 0..(N * 4) {
267            if count == N {
268                break;
269            }
270            let mut call = Pipe::call(fan, ());
271            match core::pin::Pin::new(&mut call).poll(cx) {
272                core::task::Poll::Ready(Ok(value)) => {
273                    out[count] = value;
274                    count += 1;
275                }
276                core::task::Poll::Ready(Err(super::Exhausted)) => break,
277                core::task::Poll::Pending => {}
278            }
279        }
280        out
281    }
282}