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