Skip to main content

specification_core/
lib.rs

1#![forbid(unsafe_code)]
2//! Composable, strongly typed business rules.
3//!
4//! A [`Specification`] evaluates a borrowed candidate. Specifications compose
5//! through [`Specification::and`], [`Specification::or`], and
6//! [`Specification::not`] without dynamic dispatch or heap allocation.
7//!
8//! ```
9//! use specification_core::Specification;
10//!
11//! let is_adult = |age: &u8| *age >= 18;
12//! let is_senior = |age: &u8| *age >= 65;
13//! let eligible = is_adult.and(is_senior.not());
14//!
15//! assert!(eligible.is_satisfied_by(&42));
16//! assert!(!eligible.is_satisfied_by(&70));
17//! ```
18
19use std::{collections::BTreeMap, future::Future, pin::Pin, sync::Arc, time::Duration};
20
21/// A deterministic boolean rule over a borrowed candidate.
22///
23/// Implement this trait for a focused business rule, then compose it with the
24/// provided boolean combinators. Evaluation borrows the candidate, so rules do
25/// not take ownership of the value they inspect.
26pub trait Specification<T: ?Sized> {
27    /// Returns whether `candidate` satisfies this rule.
28    fn is_satisfied_by(&self, candidate: &T) -> bool;
29
30    /// Combines this rule with `other` using short-circuiting logical AND.
31    ///
32    /// The resulting [`And`] evaluates this rule first. It evaluates `other`
33    /// only when this rule returns `true`.
34    fn and<Other>(self, other: Other) -> And<Self, Other>
35    where
36        Self: Sized,
37        Other: Specification<T>,
38    {
39        And {
40            left: self,
41            right: other,
42        }
43    }
44
45    /// Combines this rule with `other` using short-circuiting logical OR.
46    ///
47    /// The resulting [`Or`] evaluates this rule first. It evaluates `other`
48    /// only when this rule returns `false`.
49    fn or<Other>(self, other: Other) -> Or<Self, Other>
50    where
51        Self: Sized,
52        Other: Specification<T>,
53    {
54        Or {
55            left: self,
56            right: other,
57        }
58    }
59
60    /// Creates a rule that negates this rule's result.
61    fn not(self) -> Not<Self>
62    where
63        Self: Sized,
64    {
65        Not { inner: self }
66    }
67}
68
69impl<T: ?Sized, Predicate> Specification<T> for Predicate
70where
71    Predicate: Fn(&T) -> bool,
72{
73    fn is_satisfied_by(&self, candidate: &T) -> bool {
74        self(candidate)
75    }
76}
77
78/// A runtime-neutral asynchronous rule over a borrowed, `Sync` candidate.
79///
80/// The returned future is `Send`, so callers may hand it to an executor that
81/// moves work between threads. This trait uses return-position `impl Future`
82/// and is therefore not directly dyn-compatible; use
83/// [`BoxedAsyncSpecification`] when object-safe dynamic dispatch is required.
84/// No executor or async runtime is selected by this API.
85///
86/// ```compile_fail
87/// use specification_core::AsyncSpecification;
88///
89/// fn accepts_dynamic(_: &dyn AsyncSpecification<i32, Error = &'static str>) {}
90/// ```
91pub trait AsyncSpecification<T: ?Sized + Sync>: Sync {
92    /// The typed error produced by asynchronous evaluation.
93    type Error: Send;
94
95    /// Evaluates the rule and returns a `Send` future with a typed result.
96    fn is_satisfied_by<'candidate>(
97        &'candidate self,
98        candidate: &'candidate T,
99    ) -> impl Future<Output = Result<bool, Self::Error>> + Send + 'candidate;
100
101    /// Combines this rule with `other` using asynchronous short-circuiting AND.
102    fn and<Other>(self, other: Other) -> AsyncAnd<Self, Other>
103    where
104        Self: Sized,
105        Other: AsyncSpecification<T, Error = Self::Error>,
106    {
107        AsyncAnd {
108            left: self,
109            right: other,
110        }
111    }
112
113    /// Combines this rule with `other` using asynchronous short-circuiting OR.
114    fn or<Other>(self, other: Other) -> AsyncOr<Self, Other>
115    where
116        Self: Sized,
117        Other: AsyncSpecification<T, Error = Self::Error>,
118    {
119        AsyncOr {
120            left: self,
121            right: other,
122        }
123    }
124
125    /// Creates an asynchronous rule that negates this rule's result.
126    fn not(self) -> AsyncNot<Self>
127    where
128        Self: Sized,
129    {
130        AsyncNot { inner: self }
131    }
132}
133
134/// An asynchronous specification that requires both operands to succeed.
135pub struct AsyncAnd<Left, Right> {
136    left: Left,
137    right: Right,
138}
139
140impl<T: ?Sized + Sync, Left, Right> AsyncSpecification<T> for AsyncAnd<Left, Right>
141where
142    Left: AsyncSpecification<T>,
143    Right: AsyncSpecification<T, Error = Left::Error>,
144{
145    type Error = Left::Error;
146
147    #[allow(clippy::manual_async_fn)]
148    fn is_satisfied_by<'candidate>(
149        &'candidate self,
150        candidate: &'candidate T,
151    ) -> impl Future<Output = Result<bool, Self::Error>> + Send + 'candidate {
152        async move {
153            if !self.left.is_satisfied_by(candidate).await? {
154                return Ok(false);
155            }
156            self.right.is_satisfied_by(candidate).await
157        }
158    }
159}
160
161/// An asynchronous specification that accepts either operand.
162pub struct AsyncOr<Left, Right> {
163    left: Left,
164    right: Right,
165}
166
167impl<T: ?Sized + Sync, Left, Right> AsyncSpecification<T> for AsyncOr<Left, Right>
168where
169    Left: AsyncSpecification<T>,
170    Right: AsyncSpecification<T, Error = Left::Error>,
171{
172    type Error = Left::Error;
173
174    #[allow(clippy::manual_async_fn)]
175    fn is_satisfied_by<'candidate>(
176        &'candidate self,
177        candidate: &'candidate T,
178    ) -> impl Future<Output = Result<bool, Self::Error>> + Send + 'candidate {
179        async move {
180            if self.left.is_satisfied_by(candidate).await? {
181                return Ok(true);
182            }
183            self.right.is_satisfied_by(candidate).await
184        }
185    }
186}
187
188/// An asynchronous specification that negates another specification.
189pub struct AsyncNot<Inner> {
190    inner: Inner,
191}
192
193impl<T: ?Sized + Sync, Inner> AsyncSpecification<T> for AsyncNot<Inner>
194where
195    Inner: AsyncSpecification<T>,
196{
197    type Error = Inner::Error;
198
199    #[allow(clippy::manual_async_fn)]
200    fn is_satisfied_by<'candidate>(
201        &'candidate self,
202        candidate: &'candidate T,
203    ) -> impl Future<Output = Result<bool, Self::Error>> + Send + 'candidate {
204        async move {
205            self.inner
206                .is_satisfied_by(candidate)
207                .await
208                .map(|result| !result)
209        }
210    }
211}
212
213/// A boxed `Send` future used by the object-safe asynchronous adapter.
214pub type BoxFuture<'future, Error> =
215    Pin<Box<dyn Future<Output = Result<bool, Error>> + Send + 'future>>;
216
217trait DynAsyncSpecification<T: ?Sized + Sync, Error>: Send + Sync {
218    fn evaluate<'candidate>(
219        &'candidate self,
220        candidate: &'candidate T,
221    ) -> BoxFuture<'candidate, Error>;
222}
223
224impl<T: ?Sized + Sync, Error, Concrete> DynAsyncSpecification<T, Error> for Concrete
225where
226    Concrete: AsyncSpecification<T, Error = Error> + Send + Sync,
227{
228    fn evaluate<'candidate>(
229        &'candidate self,
230        candidate: &'candidate T,
231    ) -> BoxFuture<'candidate, Error> {
232        Box::pin(self.is_satisfied_by(candidate))
233    }
234}
235
236/// An explicitly type-erased asynchronous specification.
237///
238/// This is the object-safe boundary for [`AsyncSpecification`]. Construction
239/// requires `Send + Sync + 'static`; the adapter allocates one boxed future per
240/// evaluation, while static async composition keeps its concrete future type.
241pub struct BoxedAsyncSpecification<T: ?Sized + Sync, Error: Send> {
242    inner: Box<dyn DynAsyncSpecification<T, Error>>,
243}
244
245impl<T: ?Sized + Sync, Error: Send> BoxedAsyncSpecification<T, Error> {
246    /// Erases and owns a `Send + Sync` asynchronous specification.
247    pub fn new<Concrete>(specification: Concrete) -> Self
248    where
249        Concrete: AsyncSpecification<T, Error = Error> + Send + Sync + 'static,
250    {
251        Self {
252            inner: Box::new(specification),
253        }
254    }
255}
256
257impl<T: ?Sized + Sync, Error: Send> AsyncSpecification<T> for BoxedAsyncSpecification<T, Error> {
258    type Error = Error;
259
260    #[allow(clippy::manual_async_fn)]
261    fn is_satisfied_by<'candidate>(
262        &'candidate self,
263        candidate: &'candidate T,
264    ) -> impl Future<Output = Result<bool, Self::Error>> + Send + 'candidate {
265        self.inner.evaluate(candidate)
266    }
267}
268
269/// A specification that requires both of two specifications to be satisfied.
270///
271/// Construct this type with [`Specification::and`]. Its operands remain
272/// concrete types, so the composition does not allocate or use dynamic
273/// dispatch.
274pub struct And<Left, Right> {
275    left: Left,
276    right: Right,
277}
278
279impl<T: ?Sized, Left, Right> Specification<T> for And<Left, Right>
280where
281    Left: Specification<T>,
282    Right: Specification<T>,
283{
284    fn is_satisfied_by(&self, candidate: &T) -> bool {
285        self.left.is_satisfied_by(candidate) && self.right.is_satisfied_by(candidate)
286    }
287}
288
289/// A specification that requires either of two specifications to be satisfied.
290///
291/// Construct this type with [`Specification::or`]. Its operands remain
292/// concrete types, so the composition does not allocate or use dynamic
293/// dispatch.
294pub struct Or<Left, Right> {
295    left: Left,
296    right: Right,
297}
298
299impl<T: ?Sized, Left, Right> Specification<T> for Or<Left, Right>
300where
301    Left: Specification<T>,
302    Right: Specification<T>,
303{
304    fn is_satisfied_by(&self, candidate: &T) -> bool {
305        self.left.is_satisfied_by(candidate) || self.right.is_satisfied_by(candidate)
306    }
307}
308
309/// A specification that negates another specification's result.
310///
311/// Construct this type with [`Specification::not`].
312pub struct Not<Inner> {
313    inner: Inner,
314}
315
316impl<T: ?Sized, Inner> Specification<T> for Not<Inner>
317where
318    Inner: Specification<T>,
319{
320    fn is_satisfied_by(&self, candidate: &T) -> bool {
321        !self.inner.is_satisfied_by(candidate)
322    }
323}
324
325/// A specification that always returns `true`.
326#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
327pub struct Always;
328
329impl<T: ?Sized> Specification<T> for Always {
330    fn is_satisfied_by(&self, _: &T) -> bool {
331        true
332    }
333}
334
335/// A specification that always returns `false`.
336#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
337pub struct Never;
338
339impl<T: ?Sized> Specification<T> for Never {
340    fn is_satisfied_by(&self, _: &T) -> bool {
341        false
342    }
343}
344
345/// A borrowed collection evaluated with logical AND.
346///
347/// An empty collection is satisfied, the identity for logical AND. Evaluation
348/// stops at the first unsatisfied specification.
349pub struct AllOf<'specifications, SpecificationType> {
350    specifications: &'specifications [SpecificationType],
351}
352
353impl<'specifications, SpecificationType> AllOf<'specifications, SpecificationType> {
354    /// Creates an all-of specification that borrows `specifications`.
355    pub const fn new(specifications: &'specifications [SpecificationType]) -> Self {
356        Self { specifications }
357    }
358}
359
360impl<T: ?Sized, SpecificationType> Specification<T> for AllOf<'_, SpecificationType>
361where
362    SpecificationType: Specification<T>,
363{
364    fn is_satisfied_by(&self, candidate: &T) -> bool {
365        self.specifications
366            .iter()
367            .all(|specification| specification.is_satisfied_by(candidate))
368    }
369}
370
371/// A borrowed collection evaluated with logical OR.
372///
373/// An empty collection is not satisfied, the identity for logical OR.
374/// Evaluation stops at the first satisfied specification.
375pub struct AnyOf<'specifications, SpecificationType> {
376    specifications: &'specifications [SpecificationType],
377}
378
379impl<'specifications, SpecificationType> AnyOf<'specifications, SpecificationType> {
380    /// Creates an any-of specification that borrows `specifications`.
381    pub const fn new(specifications: &'specifications [SpecificationType]) -> Self {
382        Self { specifications }
383    }
384}
385
386impl<T: ?Sized, SpecificationType> Specification<T> for AnyOf<'_, SpecificationType>
387where
388    SpecificationType: Specification<T>,
389{
390    fn is_satisfied_by(&self, candidate: &T) -> bool {
391        self.specifications
392            .iter()
393            .any(|specification| specification.is_satisfied_by(candidate))
394    }
395}
396
397/// An explicitly type-erased, owned specification.
398///
399/// Use this wrapper only when a runtime-selected or heterogeneous collection of
400/// rules is needed. Static composition remains the allocation-free default.
401pub struct BoxedSpecification<T: ?Sized> {
402    inner: Box<dyn Specification<T>>,
403}
404
405impl<T: ?Sized> BoxedSpecification<T> {
406    /// Erases the concrete type of `specification` and takes ownership of it.
407    pub fn new<Concrete>(specification: Concrete) -> Self
408    where
409        Concrete: Specification<T> + 'static,
410    {
411        Self {
412            inner: Box::new(specification),
413        }
414    }
415}
416
417impl<T: ?Sized> Specification<T> for BoxedSpecification<T> {
418    fn is_satisfied_by(&self, candidate: &T) -> bool {
419        self.inner.is_satisfied_by(candidate)
420    }
421}
422
423/// An explicitly type-erased specification that can be shared across threads.
424///
425/// The `Send + Sync` bounds belong to the erased trait object and the
426/// constructor, so sharing is an explicit API choice. The ordinary
427/// [`Specification`] trait remains usable for single-threaded rules that do
428/// not satisfy those bounds.
429///
430/// A captured non-thread-safe value is rejected at the shared boundary:
431///
432/// ```compile_fail
433/// use std::rc::Rc;
434/// use specification_core::SharedSpecification;
435///
436/// let state = Rc::new(1_u8);
437/// let _rule = SharedSpecification::new(move |_: &u8| *state == 1);
438/// ```
439pub struct SharedSpecification<T: ?Sized> {
440    inner: Arc<dyn Specification<T> + Send + Sync>,
441}
442
443impl<T: ?Sized> Clone for SharedSpecification<T> {
444    fn clone(&self) -> Self {
445        Self {
446            inner: Arc::clone(&self.inner),
447        }
448    }
449}
450
451impl<T: ?Sized> SharedSpecification<T> {
452    /// Erases and shares a `Send + Sync` specification.
453    pub fn new<Concrete>(specification: Concrete) -> Self
454    where
455        Concrete: Specification<T> + Send + Sync + 'static,
456    {
457        Self {
458            inner: Arc::new(specification),
459        }
460    }
461}
462
463impl<T: ?Sized> Specification<T> for SharedSpecification<T> {
464    fn is_satisfied_by(&self, candidate: &T) -> bool {
465        self.inner.is_satisfied_by(candidate)
466    }
467}
468
469/// A rule that selects a typed decision for a candidate.
470pub trait DecisionSpecification<T: ?Sized> {
471    /// The decision selected by this rule.
472    type Decision;
473
474    /// Returns the selected decision, or `None` when no rule matches.
475    fn decide(&self, candidate: &T) -> Option<&Self::Decision>;
476}
477
478/// Ordered, typed decisions selected by the first matching specification.
479///
480/// Rules are evaluated in insertion order. The first satisfied rule wins and
481/// later rules are not evaluated.
482pub struct FirstMatch<T: ?Sized, Decision> {
483    rules: Vec<(BoxedSpecification<T>, Decision)>,
484}
485
486impl<T: ?Sized, Decision> FirstMatch<T, Decision> {
487    /// Creates a first-match decision set with no rules.
488    pub const fn new() -> Self {
489        Self { rules: Vec::new() }
490    }
491
492    /// Appends a rule and its decision, preserving evaluation order.
493    pub fn push<Concrete>(&mut self, specification: Concrete, decision: Decision)
494    where
495        Concrete: Specification<T> + 'static,
496    {
497        self.rules
498            .push((BoxedSpecification::new(specification), decision));
499    }
500
501    /// Returns the first matching decision or `fallback` when no rule matches.
502    pub fn decide_or<'decision>(
503        &'decision self,
504        candidate: &T,
505        fallback: &'decision Decision,
506    ) -> &'decision Decision {
507        self.decide(candidate).unwrap_or(fallback)
508    }
509}
510
511impl<T: ?Sized, Decision> Default for FirstMatch<T, Decision> {
512    fn default() -> Self {
513        Self::new()
514    }
515}
516
517impl<T: ?Sized, Decision> DecisionSpecification<T> for FirstMatch<T, Decision> {
518    type Decision = Decision;
519
520    fn decide(&self, candidate: &T) -> Option<&Decision> {
521        self.rules.iter().find_map(|(specification, decision)| {
522            specification.is_satisfied_by(candidate).then_some(decision)
523        })
524    }
525}
526
527/// Immutable input data for specifications that need counters, flags, time,
528/// and application-specific user data.
529///
530/// The context has no interior mutability or global registration mechanism. It
531/// is `Send` and `Sync` when `UserData` and its standard-library fields are,
532/// allowing callers to move or share a context according to the auto-trait
533/// rules of the user data they provide.
534pub struct EvaluationContext<UserData> {
535    user_data: UserData,
536    counters: BTreeMap<String, u64>,
537    flags: BTreeMap<String, bool>,
538    timestamps: BTreeMap<String, Duration>,
539}
540
541impl<UserData> EvaluationContext<UserData> {
542    /// Creates an empty context containing typed `user_data`.
543    pub fn new(user_data: UserData) -> Self {
544        Self {
545            user_data,
546            counters: BTreeMap::new(),
547            flags: BTreeMap::new(),
548            timestamps: BTreeMap::new(),
549        }
550    }
551
552    /// Adds or replaces a counter while constructing the context.
553    pub fn with_counter(mut self, key: impl Into<String>, value: u64) -> Self {
554        self.counters.insert(key.into(), value);
555        self
556    }
557
558    /// Adds or replaces a flag while constructing the context.
559    pub fn with_flag(mut self, key: impl Into<String>, value: bool) -> Self {
560        self.flags.insert(key.into(), value);
561        self
562    }
563
564    /// Adds or replaces a timestamp while constructing the context.
565    pub fn with_timestamp(mut self, key: impl Into<String>, value: Duration) -> Self {
566        self.timestamps.insert(key.into(), value);
567        self
568    }
569
570    /// Returns the typed application data.
571    pub fn user_data(&self) -> &UserData {
572        &self.user_data
573    }
574
575    /// Returns a counter, treating a missing key as zero.
576    pub fn counter(&self, key: &str) -> u64 {
577        self.counters.get(key).copied().unwrap_or_default()
578    }
579
580    /// Returns a flag, treating a missing key as false.
581    pub fn flag(&self, key: &str) -> bool {
582        self.flags.get(key).copied().unwrap_or_default()
583    }
584
585    /// Returns the timestamp stored for `key`, if one exists.
586    pub fn timestamp(&self, key: &str) -> Option<Duration> {
587        self.timestamps.get(key).copied()
588    }
589}
590
591/// A deterministic source of evaluation time supplied by the caller.
592///
593/// Implementations may use synchronization primitives when they need mutable
594/// state, but the provider is owned by the specification that receives it.
595/// There is no ambient or global clock.
596pub trait Clock {
597    /// Returns the current time in the clock's chosen monotonic epoch.
598    fn now(&self) -> Duration;
599}
600
601/// A clock fixed at one instant, useful for deterministic evaluation and tests.
602#[derive(Clone, Copy, Debug, Eq, PartialEq)]
603pub struct FixedClock(Duration);
604
605impl FixedClock {
606    /// Creates a clock that always returns `now`.
607    pub const fn new(now: Duration) -> Self {
608        Self(now)
609    }
610}
611
612impl Clock for FixedClock {
613    fn now(&self) -> Duration {
614        self.0
615    }
616}
617
618/// A specification satisfied while a named counter remains strictly below a maximum.
619pub struct MaxCount {
620    key: String,
621    maximum_count: u64,
622}
623
624impl MaxCount {
625    /// Creates a counter rule with the strict condition `counter < maximum_count`.
626    pub fn new(key: impl Into<String>, maximum_count: u64) -> Self {
627        Self {
628            key: key.into(),
629            maximum_count,
630        }
631    }
632}
633
634impl<UserData> Specification<EvaluationContext<UserData>> for MaxCount {
635    fn is_satisfied_by(&self, context: &EvaluationContext<UserData>) -> bool {
636        context.counter(&self.key) < self.maximum_count
637    }
638}
639
640/// A specification satisfied when a named context flag is true.
641pub struct Flag {
642    key: String,
643}
644
645impl Flag {
646    /// Creates a rule for a named boolean flag.
647    pub fn new(key: impl Into<String>) -> Self {
648        Self { key: key.into() }
649    }
650}
651
652impl<UserData> Specification<EvaluationContext<UserData>> for Flag {
653    fn is_satisfied_by(&self, context: &EvaluationContext<UserData>) -> bool {
654        context.flag(&self.key)
655    }
656}
657
658/// A specification satisfied after a named timestamp is at least one duration old.
659///
660/// `clock` is dependency-injected. A `Cooldown` can therefore be shared only
661/// when its clock implementation is also `Send + Sync`.
662pub struct Cooldown<ClockType> {
663    key: String,
664    duration: Duration,
665    clock: ClockType,
666}
667
668impl<ClockType> Cooldown<ClockType> {
669    /// Creates a cooldown rule using an explicitly provided clock.
670    pub fn new(key: impl Into<String>, duration: Duration, clock: ClockType) -> Self {
671        Self {
672            key: key.into(),
673            duration,
674            clock,
675        }
676    }
677}
678
679impl<UserData, ClockType> Specification<EvaluationContext<UserData>> for Cooldown<ClockType>
680where
681    ClockType: Clock,
682{
683    fn is_satisfied_by(&self, context: &EvaluationContext<UserData>) -> bool {
684        context.timestamp(&self.key).is_none_or(|timestamp| {
685            let now = self.clock.now();
686            now >= timestamp && now - timestamp >= self.duration
687        })
688    }
689}
690
691#[cfg(test)]
692mod tests {
693    use std::cell::Cell;
694    use std::future::{Future, ready};
695    use std::rc::Rc;
696    use std::sync::{
697        Arc,
698        atomic::{AtomicUsize, Ordering},
699    };
700    use std::task::{Context, Poll, Waker};
701    use std::thread;
702    use std::time::Duration;
703
704    use super::{
705        AllOf, Always, AnyOf, AsyncSpecification, BoxedAsyncSpecification, BoxedSpecification,
706        Clock, Cooldown, DecisionSpecification, EvaluationContext, FirstMatch, FixedClock, Flag,
707        MaxCount, Never, SharedSpecification, Specification,
708    };
709
710    fn assert_send_sync<T: Send + Sync>() {}
711
712    struct CountingSpecification {
713        result: bool,
714        evaluations: Rc<Cell<u8>>,
715    }
716
717    impl CountingSpecification {
718        fn new(result: bool) -> Self {
719            Self {
720                result,
721                evaluations: Rc::new(Cell::new(0)),
722            }
723        }
724
725        fn counter(&self) -> Rc<Cell<u8>> {
726            Rc::clone(&self.evaluations)
727        }
728    }
729
730    impl Specification<i32> for CountingSpecification {
731        fn is_satisfied_by(&self, _: &i32) -> bool {
732            self.evaluations.set(self.evaluations.get() + 1);
733            self.result
734        }
735    }
736
737    #[derive(Clone)]
738    struct ImmediateAsyncSpecification {
739        result: Result<bool, &'static str>,
740        evaluations: Arc<AtomicUsize>,
741    }
742
743    impl ImmediateAsyncSpecification {
744        fn new(result: Result<bool, &'static str>) -> Self {
745            Self {
746                result,
747                evaluations: Arc::new(AtomicUsize::new(0)),
748            }
749        }
750
751        fn counter(&self) -> Arc<AtomicUsize> {
752            Arc::clone(&self.evaluations)
753        }
754    }
755
756    impl AsyncSpecification<i32> for ImmediateAsyncSpecification {
757        type Error = &'static str;
758
759        fn is_satisfied_by<'candidate>(
760            &'candidate self,
761            _: &'candidate i32,
762        ) -> impl Future<Output = Result<bool, Self::Error>> + Send + 'candidate {
763            self.evaluations.fetch_add(1, Ordering::Relaxed);
764            ready(self.result)
765        }
766    }
767
768    fn block_on<F: Future>(future: F) -> F::Output {
769        let waker = Waker::noop();
770        let mut context = Context::from_waker(waker);
771        let mut future = std::pin::pin!(future);
772
773        loop {
774            match future.as_mut().poll(&mut context) {
775                Poll::Ready(output) => return output,
776                Poll::Pending => std::thread::yield_now(),
777            }
778        }
779    }
780
781    #[test]
782    fn closures_are_specifications() {
783        let is_positive = |number: &i32| *number > 0;
784
785        assert!(is_positive.is_satisfied_by(&1));
786        assert!(!is_positive.is_satisfied_by(&0));
787    }
788
789    #[test]
790    fn and_requires_both_rules_to_succeed() {
791        let between = (|number: &i32| *number > 5).and(|number: &i32| *number < 10);
792
793        assert!(between.is_satisfied_by(&7));
794        assert!(!between.is_satisfied_by(&5));
795        assert!(!between.is_satisfied_by(&10));
796    }
797
798    #[test]
799    fn and_short_circuits_after_a_false_left_side() {
800        let left = CountingSpecification::new(false);
801        let right = CountingSpecification::new(true);
802        let left_evaluations = left.counter();
803        let right_evaluations = right.counter();
804        let specification = left.and(right);
805
806        assert!(!specification.is_satisfied_by(&0));
807        assert_eq!(left_evaluations.get(), 1);
808        assert_eq!(right_evaluations.get(), 0);
809    }
810
811    #[test]
812    fn or_accepts_either_rule() {
813        let outside = (|number: &i32| *number < 0).or(|number: &i32| *number > 10);
814
815        assert!(outside.is_satisfied_by(&-1));
816        assert!(outside.is_satisfied_by(&11));
817        assert!(!outside.is_satisfied_by(&5));
818    }
819
820    #[test]
821    fn or_short_circuits_after_a_true_left_side() {
822        let left = CountingSpecification::new(true);
823        let right = CountingSpecification::new(false);
824        let left_evaluations = left.counter();
825        let right_evaluations = right.counter();
826        let specification = left.or(right);
827
828        assert!(specification.is_satisfied_by(&0));
829        assert_eq!(left_evaluations.get(), 1);
830        assert_eq!(right_evaluations.get(), 0);
831    }
832
833    #[test]
834    fn not_negates_a_rule() {
835        let is_even = |number: &i32| *number % 2 == 0;
836        let is_odd = is_even.not();
837
838        assert!(is_odd.is_satisfied_by(&3));
839        assert!(!is_odd.is_satisfied_by(&4));
840    }
841
842    #[test]
843    fn constants_have_expected_results() {
844        assert!(Always.is_satisfied_by(&"candidate"));
845        assert!(!Never.is_satisfied_by(&"candidate"));
846    }
847
848    #[test]
849    fn all_of_uses_the_and_identity_and_short_circuits() {
850        let empty: [CountingSpecification; 0] = [];
851        let failing = CountingSpecification::new(false);
852        let skipped = CountingSpecification::new(true);
853        let failing_evaluations = failing.counter();
854        let skipped_evaluations = skipped.counter();
855        let specifications = [failing, skipped];
856
857        assert!(AllOf::new(&empty).is_satisfied_by(&0));
858        assert!(!AllOf::new(&specifications).is_satisfied_by(&0));
859        assert_eq!(failing_evaluations.get(), 1);
860        assert_eq!(skipped_evaluations.get(), 0);
861    }
862
863    #[test]
864    fn any_of_uses_the_or_identity_and_short_circuits() {
865        let empty: [CountingSpecification; 0] = [];
866        let succeeding = CountingSpecification::new(true);
867        let skipped = CountingSpecification::new(false);
868        let succeeding_evaluations = succeeding.counter();
869        let skipped_evaluations = skipped.counter();
870        let specifications = [succeeding, skipped];
871
872        assert!(!AnyOf::new(&empty).is_satisfied_by(&0));
873        assert!(AnyOf::new(&specifications).is_satisfied_by(&0));
874        assert_eq!(succeeding_evaluations.get(), 1);
875        assert_eq!(skipped_evaluations.get(), 0);
876    }
877
878    #[test]
879    fn boxed_specifications_store_heterogeneous_rules() {
880        let specifications: Vec<BoxedSpecification<i32>> = vec![
881            BoxedSpecification::new(|number: &i32| *number > 0),
882            BoxedSpecification::new(Always),
883        ];
884
885        assert!(specifications[0].is_satisfied_by(&1));
886        assert!(specifications[1].is_satisfied_by(&-1));
887    }
888
889    #[test]
890    fn shared_specifications_are_cloneable() {
891        let specification = SharedSpecification::new(|number: &i32| *number % 2 == 0);
892        let shared = specification.clone();
893
894        assert!(shared.is_satisfied_by(&2));
895        assert!(!specification.is_satisfied_by(&3));
896    }
897
898    #[test]
899    fn shared_api_and_immutable_context_expose_expected_auto_traits() {
900        assert_send_sync::<SharedSpecification<i32>>();
901        assert_send_sync::<EvaluationContext<String>>();
902        assert_send_sync::<Cooldown<FixedClock>>();
903    }
904
905    #[test]
906    fn shared_specification_can_be_evaluated_concurrently() {
907        let specification = SharedSpecification::new(|number: &u64| *number % 2 == 0);
908        let handles = (0..8)
909            .map(|_| {
910                let specification = specification.clone();
911                thread::spawn(move || {
912                    (0..1_000_u64)
913                        .filter(|number| specification.is_satisfied_by(number))
914                        .count()
915                })
916            })
917            .collect::<Vec<_>>();
918
919        for handle in handles {
920            assert_eq!(handle.join().expect("worker thread should finish"), 500);
921        }
922    }
923
924    #[test]
925    fn async_and_short_circuits_and_propagates_errors() {
926        let left = ImmediateAsyncSpecification::new(Ok(false));
927        let right = ImmediateAsyncSpecification::new(Err("right"));
928        let right_evaluations = right.counter();
929        let specification = left.and(right);
930
931        assert_eq!(block_on(specification.is_satisfied_by(&0)), Ok(false));
932        assert_eq!(right_evaluations.load(Ordering::Relaxed), 0);
933
934        let failing_left = ImmediateAsyncSpecification::new(Err("left"));
935        let successful_right = ImmediateAsyncSpecification::new(Ok(true));
936        let successful_right_evaluations = successful_right.counter();
937        let specification = failing_left.and(successful_right);
938
939        assert_eq!(block_on(specification.is_satisfied_by(&0)), Err("left"));
940        assert_eq!(successful_right_evaluations.load(Ordering::Relaxed), 0);
941    }
942
943    #[test]
944    fn async_or_short_circuits_and_async_not_negates() {
945        let left = ImmediateAsyncSpecification::new(Ok(true));
946        let right = ImmediateAsyncSpecification::new(Err("right"));
947        let right_evaluations = right.counter();
948        let specification = left.or(right);
949
950        assert_eq!(block_on(specification.is_satisfied_by(&0)), Ok(true));
951        assert_eq!(right_evaluations.load(Ordering::Relaxed), 0);
952
953        let specification = ImmediateAsyncSpecification::new(Ok(false)).not();
954        assert_eq!(block_on(specification.is_satisfied_by(&0)), Ok(true));
955
956        let specification = ImmediateAsyncSpecification::new(Err("failure")).not();
957        assert_eq!(block_on(specification.is_satisfied_by(&0)), Err("failure"));
958    }
959
960    #[test]
961    fn boxed_async_specification_is_an_explicit_dynamic_boundary() {
962        assert_send_sync::<BoxedAsyncSpecification<i32, &'static str>>();
963        let specification =
964            BoxedAsyncSpecification::new(ImmediateAsyncSpecification::new(Ok(true)));
965
966        assert_eq!(block_on(specification.is_satisfied_by(&0)), Ok(true));
967    }
968
969    #[derive(Clone)]
970    struct CountingClock {
971        now: Duration,
972        reads: Arc<AtomicUsize>,
973    }
974
975    impl Clock for CountingClock {
976        fn now(&self) -> Duration {
977            self.reads.fetch_add(1, Ordering::Relaxed);
978            self.now
979        }
980    }
981
982    #[test]
983    fn injected_thread_safe_clock_can_back_a_shared_cooldown() {
984        let reads = Arc::new(AtomicUsize::new(0));
985        let clock = CountingClock {
986            now: Duration::from_secs(100),
987            reads: Arc::clone(&reads),
988        };
989        let specification =
990            SharedSpecification::new(Cooldown::new("last_login", Duration::from_secs(60), clock));
991        let handles = (0..4)
992            .map(|_| {
993                let specification = specification.clone();
994                let context = EvaluationContext::new(())
995                    .with_timestamp("last_login", Duration::from_secs(40));
996                thread::spawn(move || specification.is_satisfied_by(&context))
997            })
998            .collect::<Vec<_>>();
999
1000        for handle in handles {
1001            assert!(handle.join().expect("worker thread should finish"));
1002        }
1003        assert_eq!(reads.load(Ordering::Relaxed), 4);
1004    }
1005
1006    #[test]
1007    fn first_match_returns_the_first_typed_decision_and_short_circuits() {
1008        let first = CountingSpecification::new(true);
1009        let later = CountingSpecification::new(true);
1010        let first_evaluations = first.counter();
1011        let later_evaluations = later.counter();
1012        let mut decisions = FirstMatch::new();
1013        decisions.push(first, "first");
1014        decisions.push(later, "later");
1015
1016        assert_eq!(decisions.decide(&0), Some(&"first"));
1017        assert_eq!(first_evaluations.get(), 1);
1018        assert_eq!(later_evaluations.get(), 0);
1019    }
1020
1021    #[test]
1022    fn first_match_exposes_no_match_and_explicit_fallback() {
1023        let mut decisions = FirstMatch::new();
1024        decisions.push(|number: &i32| *number > 10, "large");
1025        let fallback = "default";
1026
1027        assert_eq!(decisions.decide(&5), None);
1028        assert_eq!(decisions.decide_or(&5, &fallback), &fallback);
1029    }
1030
1031    #[test]
1032    fn context_preserves_typed_user_data_and_default_values() {
1033        let context = EvaluationContext::new("user").with_counter("visits", 3);
1034
1035        assert_eq!(context.user_data(), &"user");
1036        assert_eq!(context.counter("visits"), 3);
1037        assert_eq!(context.counter("missing"), 0);
1038        assert!(!context.flag("missing"));
1039        assert_eq!(context.timestamp("missing"), None);
1040    }
1041
1042    #[test]
1043    fn max_count_uses_a_strict_boundary() {
1044        let specification = MaxCount::new("attempts", 3);
1045
1046        assert!(
1047            specification.is_satisfied_by(&EvaluationContext::new(()).with_counter("attempts", 2))
1048        );
1049        assert!(
1050            !specification.is_satisfied_by(&EvaluationContext::new(()).with_counter("attempts", 3))
1051        );
1052        assert!(specification.is_satisfied_by(&EvaluationContext::new(())));
1053    }
1054
1055    #[test]
1056    fn flag_requires_a_true_context_value() {
1057        let specification = Flag::new("premium");
1058
1059        assert!(
1060            specification.is_satisfied_by(&EvaluationContext::new(()).with_flag("premium", true))
1061        );
1062        assert!(
1063            !specification.is_satisfied_by(&EvaluationContext::new(()).with_flag("premium", false))
1064        );
1065        assert!(!specification.is_satisfied_by(&EvaluationContext::new(())));
1066    }
1067
1068    #[test]
1069    fn cooldown_uses_an_injected_clock_and_inclusive_boundary() {
1070        let specification = Cooldown::new(
1071            "last_login",
1072            Duration::from_secs(60),
1073            FixedClock::new(Duration::from_secs(100)),
1074        );
1075
1076        assert!(specification.is_satisfied_by(&EvaluationContext::new(())));
1077        assert!(specification.is_satisfied_by(
1078            &EvaluationContext::new(()).with_timestamp("last_login", Duration::from_secs(40))
1079        ));
1080        assert!(!specification.is_satisfied_by(
1081            &EvaluationContext::new(()).with_timestamp("last_login", Duration::from_secs(41))
1082        ));
1083    }
1084
1085    #[test]
1086    fn cooldown_rejects_future_timestamps_even_at_zero_duration() {
1087        let specification = Cooldown::new(
1088            "last_login",
1089            Duration::ZERO,
1090            FixedClock::new(Duration::from_secs(100)),
1091        );
1092
1093        assert!(!specification.is_satisfied_by(
1094            &EvaluationContext::new(()).with_timestamp("last_login", Duration::from_secs(101))
1095        ));
1096    }
1097}