specification-core Guide
specification-core is an idiomatic Rust implementation of the Specification
Pattern. Release 0.1.0 provides a synchronous core, explicit dynamic and
thread-sharing boundaries, typed decisions, immutable evaluation context,
deterministic time rules, a runtime-neutral async layer, and opt-in serde and
macro crates.
This book is the reader-facing guide. The generated API reference contains complete signatures and item-level rustdoc. The same crates are also available on docs.rs.
Start here
- Getting Started — build and evaluate a first rule in a few minutes.
- Core Concepts — understand static composition, allocation boundaries, and typed outcomes.
- Decisions and Evaluation Context — route candidates and evaluate counters, flags, and cooldowns.
- Async Specifications — use typed errors without choosing an executor in the library.
- Migrating from Swift — map the Swift API to the Rust ownership and concurrency model.
What is in 0.1.0
The released API includes boolean composition, AllOf/AnyOf,
BoxedSpecification, SharedSpecification, FirstMatch,
EvaluationContext, MaxCount, Flag, Cooldown, AsyncSpecification,
RuleDocument, and the #[specification(Name)] predicate macro.
Date-range rules, segment providers, property wrappers, Combine observation,
and global context providers are intentionally outside the Rust 0.1.0
scope. Use ordinary closures or application-owned adapters for domain-specific
rules.
Documentation surfaces
- This book explains concepts, workflows, design trade-offs, and Swift migration.
- rustdoc/docs.rs is the authoritative API reference and compiles focused examples as doctests.
- Cargo examples and integration tests are the executable end-to-end scenarios linked from the relevant chapters.
Getting Started
This chapter takes a small eligibility rule from a closure to a reusable,
typed decision. The complete static version is also available as
static_composition.rs.
Add the core crate to an application:
[dependencies]
specification-core = "0.1"
Specification<T> evaluates a borrowed candidate. A closure with the shape
Fn(&T) -> bool implements the trait automatically, so a small rule needs no
boilerplate type.
use specification_core::Specification;
struct User {
age: u8,
verified: bool,
}
let is_adult = |user: &User| user.age >= 18;
let is_verified = |user: &User| user.verified;
let eligible = is_adult.and(is_verified);
let user = User { age: 21, verified: true };
assert!(eligible.is_satisfied_by(&user));
eligible is a concrete And<...> value. The static composition path does
not allocate or use dynamic dispatch, and and/or short-circuit from left to
right. Rust receives &user because evaluation borrows the candidate rather
than taking ownership of it.
use specification_core::Specification;
let is_adult = |age: &u8| *age >= 18;
let is_retired = |age: &u8| *age >= 65;
let working_age = is_adult.and(is_retired.not());
assert!(working_age.is_satisfied_by(&42));
assert!(!working_age.is_satisfied_by(&70));
AllOf and AnyOf apply the same behavior to a borrowed homogeneous slice;
their empty identities are true and false, respectively.
Where to go next
- Need heterogeneous or runtime-selected rules? Read Dynamic and shared specifications.
- Need a typed outcome instead of
bool? Read Decisions and evaluation context. - Need network or I/O-backed evaluation? Read Async Specifications.
- Need to port an existing Swift rule? Read Swift to Rust.
The rust,ignore blocks in this book are illustrative because mdBook does not
link standalone snippets to the workspace. Every complete workflow is backed
by a Cargo example, integration test, or compiled rustdoc example; links to
those sources are provided in the relevant chapter.
Core Concepts
The library applies the Specification Pattern: a small rule evaluates a candidate, and larger rules are built by composing those values. Rust’s trait system makes the static path the default; dynamic dispatch, heap allocation, and thread sharing are explicit choices.
Static and dynamic rules
Choose the smallest boundary that matches the problem:
| Need | Rust type | Cost and guarantee |
|---|---|---|
| Known rules composed at compile time | And, Or, Not | No library allocation or trait-object dispatch |
| Homogeneous collection | AllOf, AnyOf | Borrows a slice and short-circuits |
| Heterogeneous/runtime-selected rules | BoxedSpecification<T> | Owns a Box<dyn Specification<T>> |
| Rule shared between threads | SharedSpecification<T> | Owns an Arc and requires Send + Sync + 'static |
use specification_core::{BoxedSpecification, Specification};
let rules: Vec<BoxedSpecification<u64>> = vec![
BoxedSpecification::new(|value: &u64| *value > 0),
BoxedSpecification::new(|value: &u64| *value % 2 == 0),
];
assert!(rules[1].is_satisfied_by(&42));
The complete thread-sharing scenario is in
dynamic_and_concurrency.rs.
Boolean identities and short-circuiting
and, or, and not preserve left-to-right short-circuit evaluation. An
empty AllOf is true, and an empty AnyOf is false. These identities let
an application construct a rule from a possibly empty collection without
special-case control flow.
Typed decisions
FirstMatch<T, Decision> is the explicit dynamic boundary for ordered routing.
It stores heterogeneous rules with one concrete decision type, evaluates them
in insertion order, and returns the first matching Option<&Decision>. Use
decide_or when a fallback is required. See Decisions and Evaluation
Context for a complete route-selection
example.
Immutable context and dependencies
EvaluationContext<UserData> is an immutable input snapshot. Application data
is typed through UserData; counters, flags, and timestamps have deliberate
defaults. Time rules receive a Clock explicitly, so FixedClock can make a
boundary deterministic in tests. There is no global context registry.
See Concurrency Contracts for Send + Sync behavior and
Decisions and Evaluation Context for the
built-in rules.
The runtime-neutral async layer is described in Async Specifications. Optional wire-format and predicate-macro support are described in Serialization and Macros.
Concurrency contracts
The synchronous API keeps thread sharing explicit. A regular
Specification<T> does not require Send or Sync, so a local rule may use
single-threaded state when that is the correct domain model.
Use SharedSpecification<T> when a rule must cross a thread boundary. Its
constructor requires the concrete rule to be Send + Sync + 'static, and its
cloneable wrapper uses an Arc internally:
The complete runnable version is
dynamic_and_concurrency.rs.
The following snippet is illustrative in mdBook (the book test runner does not link standalone blocks against the workspace crate); the equivalent rustdoc example is compiled by Cargo’s doctest gate.
use specification_core::{SharedSpecification, Specification};
let rule = SharedSpecification::new(|value: &u64| *value % 2 == 0);
let worker_rule = rule.clone();
std::thread::spawn(move || assert!(worker_rule.is_satisfied_by(&2)))
.join()
.expect("worker thread should finish");
EvaluationContext<UserData> is immutable after construction. It can be moved
or shared whenever UserData satisfies the corresponding Rust auto-traits.
There is no global context registry.
Time-dependent rules receive their Clock explicitly. A mutable or shared
clock is therefore an application-owned dependency: use an appropriate
standard-library synchronization primitive and inject it into the rule. This
keeps concurrent behavior visible in the type graph and makes deterministic
tests possible with FixedClock.
Async Specifications
AsyncSpecification<T> is runtime-neutral. It returns a Send future with a
typed Result<bool, Error> and does not start or own an executor. Tokio,
async-std, smol, or a custom executor can await the same rule.
The runnable standard-library example is
async_composition.rs.
use std::{convert::Infallible, future::{ready, Future}};
use specification_core::AsyncSpecification;
struct AtLeast(u8);
impl AsyncSpecification<u8> for AtLeast {
type Error = Infallible;
fn is_satisfied_by<'a>(
&'a self,
age: &'a u8,
) -> impl Future<Output = Result<bool, Self::Error>> + Send + 'a {
ready(Ok(*age >= self.0))
}
}
let working_age = AtLeast(18).and(AtLeast(65).not());
let result = working_age.is_satisfied_by(&42).await?;
The future borrows both the specification and candidate. Because it is Send,
the executor may move the future between worker threads; T must therefore be
Sync. A composed async rule requires both operands to use the same error
type, usually an application-owned error enum.
and and or evaluate sequentially and short-circuit. An error from the
operand that was evaluated is returned immediately; a skipped operand is never
polled. not flips only the boolean and preserves the error.
Dynamic async rules
The trait uses return-position impl Future, so it is not directly object-safe.
Use BoxedAsyncSpecification<T, Error> when a heterogeneous or
runtime-selected collection is required. This is an explicit Send + Sync + 'static boundary and allocates one boxed future per evaluation.
Cancellation remains an executor concern: dropping the future stops observing it, while any external side effects belong to the application implementation.
Serialization and Macros
The optional integration crates keep the core dependency-free. Add only the boundary your application needs:
[dependencies]
specification-core = "0.1"
specification-core-serde = "0.1"
specification-core-macros = "0.1"
Versioned rule documents
specification-core-serde stores a declarative rule graph. It does not and
cannot serialize arbitrary closures, captured state, or trait objects. The
schema is versioned independently from crate SemVer and unknown versions are
rejected.
The complete JSON round-trip is covered by the serde crate tests and the
versioned_document.rs
example:
use specification_core_serde::{RuleDocument, RuleNode};
let document = RuleDocument::new(RuleNode::AllOf {
rules: vec![
RuleNode::Flag { key: "premium".into() },
RuleNode::Not {
rule: Box::new(RuleNode::MaxCount {
key: "attempts".into(),
maximum: 3,
}),
},
],
});
The wire shape is tagged and deterministic:
{"schema_version":1,"rule":{"kind":"flag","key":"premium"}}
RuleDocument is data, not an evaluator. Applications must bind the decoded
graph to their own typed context and clock policy.
Predicate macro
#[specification(Name)] turns one free function into a named stateless
Specification type:
use specification_core::Specification;
use specification_core_macros::specification;
struct User { age: u8 }
#[specification(Adult)]
fn is_adult(user: &User) -> bool {
user.age >= 18
}
assert!(Adult.is_satisfied_by(&User { age: 21 }));
The macro validates the immutable &Candidate argument and bool return type.
It does not add context lookup, allocation, async behavior, or generated
composition. The compiled integration example is
tests/usage.rs.
Cross-language conformance
The Rust crate is an idiomatic implementation, not a source-level translation
of the Swift package. Cross-language compatibility is therefore expressed as
observable behavior in the versioned JSON corpus at
crates/specification-core/fixtures/conformance/v1/.
Running the corpus
The Rust adapter is an integration test and is included in the normal test gate:
make test
It reads the manifest, executes every listed fixture, and verifies both the outcome and the named evaluation trace. A missing fixture, invalid JSON, or an unknown schema version fails the test.
Adapter contract
An adapter for another implementation must:
- Read
manifest.json, then each listed JSON fixture. - Require the document’s
schema_versionto be supported. - Evaluate each case with the supplied candidate and context input.
- Compare
expected.resultorexpected.decision, plus the orderedexpected.trace. - Report unsupported fixture groups explicitly; it must not silently skip them.
The trace consists of probe names, not internal method calls. It specifies the observable ordering and short-circuit behavior of composition and first-match rules.
Semantics in v1
| Group | Contract |
|---|---|
composition | AND and OR short-circuit left-to-right; NOT negates; empty all-of is true and empty any-of is false. |
first_match | Rules are ordered; the first satisfied rule wins; no match is None; an explicit fallback is returned only by fallback evaluation. |
context | Missing counters are zero, missing flags are false, MaxCount is strict <, and cooldown uses >= at the boundary. Future timestamps do not satisfy cooldown, including zero duration. |
All context timestamps and now_ms values are non-negative integer
milliseconds in an abstract monotonic epoch. Adapters must not substitute wall
clock time.
Intentional differences and limits
Rust evaluates a borrowed candidate (&T); Swift’s protocol input convention
does not need to be copied to satisfy a fixture. Rust also has no global default
context provider, property wrappers, Combine observation, or Apple-platform
date integration. Context and clocks are injected explicitly.
The v1 corpus does not cover async scheduling or typed async errors because
those would require a shared executor and error-transport contract. Rust async
behavior remains specified by the AsyncSpecification API and its tests.
specification-core-serde uses a separate versioned RuleDocument schema.
Conformance fixtures are test data, not a promise that every executable rule
can be serialized or reconstructed.
Decisions and Evaluation Context
Use a boolean Specification<T> when the result is only yes/no. Use
FirstMatch<T, Decision> when the rule set must select a typed route, tier, or
policy.
A complete context scenario
The executable version of this scenario is
context_and_decisions.rs.
use std::time::Duration;
use specification_core::{
Cooldown, DecisionSpecification, EvaluationContext, FirstMatch,
FixedClock, Flag, MaxCount, Specification,
};
let context = EvaluationContext::new("account-42")
.with_counter("attempts", 1)
.with_flag("enabled", true)
.with_timestamp("last_action", Duration::from_secs(10));
let clock = FixedClock::new(Duration::from_secs(15));
assert!(MaxCount::new("attempts", 3).is_satisfied_by(&context));
assert!(Flag::new("enabled").is_satisfied_by(&context));
assert!(Cooldown::new(
"last_action",
Duration::from_secs(5),
clock,
).is_satisfied_by(&context));
let mut route = FirstMatch::<EvaluationContext<&str>, &'static str>::new();
route.push(Flag::new("enabled"), "enabled-route");
assert_eq!(route.decide(&context), Some(&"enabled-route"));
EvaluationContext<UserData> is immutable after construction. It keeps
application data typed, while counters, flags, and timestamps are named
inputs. A missing counter reads as zero, a missing flag as false, and a missing
timestamp as None.
Boundary rules
MaxCountis satisfied only whilecounter < maximum.Flagis satisfied only when the named flag istrue.Cooldownpermits a missing timestamp.Cooldownaccepts the exactelapsed >= durationboundary.- A future timestamp never satisfies cooldown, including zero duration.
Clock is injected instead of read from global state. FixedClock is useful in
tests; an application can inject a monotonic production clock with the same
trait.
Ordered decisions and fallback
FirstMatch evaluates rules in insertion order and stops at the first match.
With no match, decide returns None. decide_or makes a fallback explicit
without adding a hidden Always rule:
let fallback = "default-route";
let selected = route.decide_or(&context, &fallback);
The decision is borrowed from the FirstMatch value. This avoids cloning a
large decision payload and makes its lifetime visible in the type system.
Indexed decisions for keyed catalogs
For a large catalog with exact string keys, IndexedFirstMatch can skip rules
whose declared key does not match the candidate. Its key projection runs once
per decide call, and lookup borrows the candidate’s string without allocating.
The index merges the matching bucket with unkeyed rules in insertion order, so
earlier fallbacks retain priority. This excerpt is illustrative because mdBook
runs guide code blocks without the crate dependency; the complete version is a
runnable Cargo example linked below:
use specification_core::{DecisionSpecification, IndexedFirstMatch};
struct Artifact<'a> { name: &'a str, is_directory: bool }
let mut classifier = IndexedFirstMatch::new(|item: &Artifact<'_>| item.name);
classifier.push_keyed("target", |item: &Artifact<'_>| item.name == "target" && item.is_directory, "rust-build");
classifier.push_unkeyed(|item: &Artifact<'_>| item.name.ends_with(".egg-info"), "python-metadata");
let item = Artifact { name: "target", is_directory: true };
assert_eq!(classifier.decide(&item), Some(&"rust-build"));
assert!(classifier.may_match("target"));
Register a rule as keyed only when that key is a necessary condition: the
predicate must never match a candidate with another projected key. Arbitrary
closures cannot be inspected to prove this. Put rules without a sound key in
the unkeyed list. Those rules are checked for every candidate and make
may_match return true for any key. Duplicate keys are allowed; large
buckets and unkeyed rules are scanned in insertion order. The index requires
Send + Sync specifications and can be shared across worker threads when the
decisions are Sync. See the runnable
indexed_decisions.rs.
Static keyed decisions
For a rule catalog known at compile time, the optional
specification-core-macros crate provides first_match!. It generates a
concrete evaluator that implements DecisionSpecification<Context> and stores
the declared specification and decision values in typed fields; its result is
borrowed from the evaluator. The projection runs once per call. Generated
dispatch uses a match on the borrowed key and
checks the matching keyed rules together with every unkeyed rule in declaration
order:
use specification_core::{DecisionSpecification, Specification};
use specification_core_macros::first_match;
struct Facts { name: String, directory: bool, parent_cargo: bool, enabled: bool }
struct Enabled;
impl Specification<Facts> for Enabled {
fn is_satisfied_by(&self, facts: &Facts) -> bool { facts.enabled }
}
let route = first_match! {
context: Facts,
decision: u16,
key: |facts: &Facts| facts.name.as_str(),
rules: [
keyed(["target", "target-alias"], |facts: &Facts| {
(facts.name == "target" || facts.name == "target-alias")
&& facts.directory && facts.parent_cargo
}, 1),
unkeyed(Enabled, 2),
]
};
This excerpt is illustrative because mdBook code blocks do not depend on the
optional macro crate. The crate’s integration suite compiles the DSL against
the real Specification and DecisionSpecification APIs:
first_match.rs.
Register a rule as keyed only when its specification cannot match a different
projected key. The macro cannot establish that contract from an arbitrary
closure. Use one or more string literal aliases for a keyed rule; duplicate
keys are valid. Unkeyed rules are checked for every key, and priority always
follows declaration order. Unknown keys run only the unkeyed rules. Choose
IndexedFirstMatch when the catalog must be assembled dynamically; the macro
is for rule sets fixed in source.
Benchmarks
The Criterion baseline compares an equivalent static closure specification with
an explicit BoxedSpecification dynamic boundary:
cargo bench -p specification-core --bench dispatch
It measures repeated single-predicate evaluation with black_box. It does not
measure allocation, construction, context lookup, composition depth, or
application throughput. Record the Rust version, target, CPU, power mode,
command, and Criterion report when comparing runs. Results are not a
performance guarantee or cross-machine comparison.
The indexed decision benchmark compares FirstMatch and
IndexedFirstMatch with a 14-rule catalog and a 214-rule catalog. The larger
catalog places 200 unique unrelated keys before the same 14 target rules, so
candidate keys miss those first 200 rules. The harness checks every generated
candidate and the aggregate checksum, including misses, before timing. It
warms both paths, alternates their order across 31 rounds, and prints all raw
samples as CSV on stdout with median summaries on stderr:
mkdir -p target/benchmark-artifacts
cargo bench -p specification-core --bench indexed_decisions \
> target/benchmark-artifacts/indexed-decisions.csv \
2> target/benchmark-artifacts/indexed-decisions-median.txt
Retain both files with the Rust version, target, CPU, power mode, and command. The run describes this workload only; it does not establish a general speedup.
The static_decisions benchmark uses the actual macro crate and core
specifications. It compares a handwritten dispatcher with a 14-key catalog and
a catalog containing 214 keyed rules: 200 distinct always-false unrelated
rules before the same 14 target rules. It checks every result and checksum
outside timing, warms both paths, alternates their order across 25 rounds, and
prints each raw sample as CSV:
mkdir -p /Volumes/FlashCard/specification-core-rs-static/benchmark-artifacts
CARGO_TARGET_DIR=/Volumes/FlashCard/specification-core-rs-static \
cargo bench -p specification-core-macros --bench static_decisions \
> /Volumes/FlashCard/specification-core-rs-static/benchmark-artifacts/static-decisions.csv \
2> /Volumes/FlashCard/specification-core-rs-static/benchmark-artifacts/static-decisions-median.txt
Record the compiler, target, machine, power mode, and command beside both files. The handwritten dispatcher is a purpose-built comparator; these synthetic measurements do not establish behavior for an application’s real catalog.
CI runs this benchmark on Linux and macOS and validates its samples with
python3 scripts/check_indexed_performance.py target/benchmark-artifacts/indexed-decisions.csv.
The indexed 214-rule median must fit 2 × indexed 14-rule median + 20 ns + 6 × combined MAD. This is a coarse same-host regression budget, not a
significance test. Raw CSV, environment and JSON summaries are job artifacts;
deterministic invocation-count tests establish which buckets are skipped.
Migrating from Swift to Rust
The Rust crate preserves the observable Specification Pattern behavior, not the Swift source API. The Swift package is a behavioral reference; ownership, trait bounds, and concurrency guarantees follow Rust conventions.
Core mapping
Swift SpecificationCore | Rust specification-core | Practical difference |
|---|---|---|
Specification | Specification<T> | Swift receives T; Rust borrows &T |
PredicateSpec / spec {} | Fn(&T) -> bool | Closures implement the trait directly |
AndSpecification / OrSpecification / NotSpecification | And / Or / Not | Both short-circuit; Rust uses named methods |
AnySpecification<T> | BoxedSpecification<T> | Explicit owned trait-object boundary |
| — | SharedSpecification<T> | Explicit Arc + Send + Sync sharing boundary |
DecisionSpec | DecisionSpecification<T> | Rust returns Option<&Decision> |
FirstMatchSpec | FirstMatch<T, Decision> | Same ordered first-match behavior |
EvaluationContext | EvaluationContext<UserData> | Generic typed data instead of [String: Any] |
DefaultContextProvider.shared | application-owned snapshot | No global mutable context provider |
Date in context | injected Clock + Duration | Deterministic, application-defined epoch |
async throws | Future<Output = Result<bool, Error>> | Typed errors and Send futures |
@Satisfies, @Maybe, @Decides | explicit method calls | No hidden context acquisition in property access |
@specs, @AutoContext | #[specification(Name)] | Rust macro is intentionally narrower |
Composition
Swift:
let adult = PredicateSpec<User> { $0.age >= 18 }
let verified = PredicateSpec<User> { $0.verified }
let eligible = adult.and(verified)
let result = eligible.isSatisfiedBy(user)
Rust:
use specification_core::Specification;
let adult = |user: &User| user.age >= 18;
let verified = |user: &User| user.verified;
let eligible = adult.and(verified);
let result = eligible.is_satisfied_by(&user);
Swift’s protocol method is by-value at the API boundary. Rust’s borrowed candidate makes ownership explicit and lets a rule inspect a large value without taking it from the caller.
Type erasure and concurrency
AnySpecification is the usual Swift answer for heterogeneous collections. In
Rust choose deliberately:
use specification_core::{BoxedSpecification, SharedSpecification};
let local = BoxedSpecification::new(|user: &User| user.age >= 18);
let shared = SharedSpecification::new(|user: &User| user.age >= 18);
BoxedSpecification owns a local Box<dyn Specification<T>>. The shared form
owns Arc<dyn Specification<T> + Send + Sync> and rejects non-thread-safe
captures at compile time. A regular specification does not need to be Send
or Sync, which keeps single-threaded rules flexible.
Context providers and property wrappers
Swift commonly obtains a fresh snapshot through DefaultContextProvider.shared
and evaluates it through a property wrapper. Rust keeps both operations
visible:
let context = EvaluationContext::new(user)
.with_flag("enabled", true)
.with_counter("attempts", 1);
let enabled = Flag::new("enabled")
.is_satisfied_by(&context);
Mutation and refresh of counters remain application responsibilities. This avoids hidden global state and makes a test’s inputs explicit.
Async and errors
Swift’s throws can carry any error conforming to Error. Rust associates one
concrete Error type with each async specification; composing rules therefore
requires a shared application error enum. Rust also requires the future to be
Send and the candidate to be Sync, making executor/thread assumptions
visible in the API.
The Rust async layer does not select Tokio or another runtime. The caller awaits the future using the runtime already chosen by the application.
What is intentionally not identical
Rust 0.1.0 does not port Swift property wrappers, Combine observation,
global providers, segments, or the full date-rule convenience surface. Those
features are platform or application concerns. Cross-language conformance
fixtures cover observable synchronous behavior—composition, first-match,
context defaults, and cooldown boundaries—rather than requiring identical
types or serialization.
For the historical source inventory and the full adopt/adapt/defer/exclude matrix, see the maintainer-only DocC Migration Map and Swift Reference Audit.
Documentation and Testing
Public behavior is documented and tested together. Focused snippets belong in
rustdoc doctests; complete scenarios belong in Cargo examples/ or integration
tests; this book holds narrative material that connects several APIs or
explains a design.
The book’s standalone snippets are marked rust,ignore when they need the
workspace crate. Every such workflow links to a compiled example or test. Run
the complete local documentation gate with:
make docs
The detailed conventions are defined in the contributor documentation guide.
Swift DocC Migration Map (Maintainer Reference)
The Swift SpecificationCore DocC corpus is conceptual source material. This
historical map records an explicit Rust destination or exclusion for every
authored DocC article and tutorial. The reader-facing comparison is
Swift to Rust; this page is traceability data and does not
promise source-level API compatibility.
For executable-source evidence and adopt/adapt/defer/exclude decisions, see the Swift reference audit.
Overview and Core API Articles
| Swift DocC source | Rust destination | Treatment |
|---|---|---|
SpecificationCore.md | crate rustdoc and this book introduction | Split API overview from conceptual guide |
Specification.md | Specification rustdoc and concepts | Rewrite trait semantics |
SpecificationOperators.md | combinator rustdoc and getting-started | Adapt operators to Rust ergonomics |
AnySpecification.md | dynamic-dispatch rustdoc and concepts | Adapt type erasure to explicit trait objects |
DecisionSpec.md | decision rustdoc and concepts | Preserve ordered typed decision rationale |
AsyncSpecification.md | async specification guide and rustdoc | Implemented as runtime-neutral RPITIT futures with explicit Send bounds |
ContextProviding.md | context rustdoc and concepts | Adapt provider model to dependency injection |
Context and Built-in Specification Articles
| Swift DocC source | Rust destination | Treatment |
|---|---|---|
EvaluationContext.md | context rustdoc and concepts | Replace string-to-Any storage with typed/generic design |
DefaultContextProvider.md | context guide | Exclude global singleton semantics |
MockContextProvider.md | testing guide and test helpers | Adapt to Rust test fixtures |
PredicateSpec.md | core rustdoc | Use closure implementation when appropriate |
FirstMatchSpec.md | decision rustdoc and example | Preserve deterministic priority behavior |
MaxCountSpec.md | built-in rules guide | Rewrite against Rust context API |
CooldownIntervalSpec.md | decisions/context guide | Implemented with an injected Clock and Duration |
TimeSinceEventSpec.md | no Rust 0.1.0 equivalent | Not ported; application-owned rule |
DateRangeSpec.md | no Rust 0.1.0 equivalent | Not ported; application-owned rule |
DateComparisonSpec.md | no Rust 0.1.0 equivalent | Not ported; application-owned rule |
Swift-specific Declarative Articles
| Swift DocC source | Rust destination | Treatment |
|---|---|---|
Satisfies.md | migration guide | Explain explicit evaluation; no property-wrapper port |
AsyncSatisfies.md | async specification guide | Rewrite as explicit future evaluation; executor remains caller-owned |
Decides.md | decision guide | Adapt to ordinary Rust values/functions |
Maybe.md | decision guide | Adapt optional decisions to Option |
SpecsMacro.md | serialization and macros guide | Implemented as an explicit predicate macro in a separate crate |
AutoContextMacro.md | serialization and macros guide | Exclude implicit context; retain explicit dependency injection |
Tutorials
| Swift DocC source | Rust destination | Treatment |
|---|---|---|
Tutorials.tutorial | SUMMARY.md | Convert tutorial hierarchy to book navigation |
GettingStartedCore.tutorial | getting-started/ | Convert prose; rewrite each Swift code resource as Rust doctest or example |
PropertyWrappersGuide.tutorial | migration/ | Explain idiomatic explicit evaluation instead of wrappers |
MacrosAndAdvanced.tutorial | guides/ and macro roadmap | Retain concepts; gate all macro claims on implementation |
Source-Code Resource Rule
All 27 Swift tutorial code resources are mapped individually during the corresponding chapter implementation. Each resource becomes one of: a Rust doctest, a complete Cargo example, an adapted conceptual snippet, or an explicit exclusion with a reason. The migration issue must preserve this resource-level traceability rather than only the tutorial-level mapping above.
Swift Reference Audit and Rust Porting Matrix (Maintainer Reference)
Historical planning artifact from Phase 0. For the current public migration walkthrough, use Swift to Rust.
Audited source: SoundBlaster/SpecificationCore
Revision: 7909e62da7ca0416dc24c8165bfd0e6d4cf16e57
Audit date: 2026-07-10
Evidence Scope
The audited Swift package has a library target, a macro target, and one test target. Its manifest declares Swift tools 5.10, Apple platform minimums, and dependencies on SwiftSyntax, MacroTesting, and the Swift DocC plugin.
The implementation contains 23 non-DocC Swift files in SpecificationCore,
three macro implementation files, and 12 XCTest source files. The test suite
includes decision, first-match, date, async, wrapper, macro, smoke, and
performance test groups. This inventory is source evidence; it does not confirm
the README’s coverage, latency, or concurrency claims.
Build Baseline
Command:
swift test
Toolchain:
Apple Swift version 6.4 (swiftlang-6.4.0.23.5 clang-2100.3.23.5)
Target: arm64-apple-macosx26.0
Result: FAIL before tests run. FirstMatchSpec.Builder.build() at
Sources/SpecificationCore/Specs/FirstMatchSpec.swift:206 has an ambiguous
call between the erased-pair initializer and generic initializer. The build
also warns that Documentation.docc is unhandled by the Swift target.
This failure prevents a current pass/fail statement about the Swift test suite. It does not invalidate source-level behavior that is independently covered by specific XCTest files; it must not be ported as a Rust behavior.
Source Evidence
| Area | Source paths | Test evidence |
|---|---|---|
| Core composition | Core/Specification.swift, Core/SpecificationOperators.swift | SpecificationCoreTests.swift |
| Type erasure and collection rules | Core/AnySpecification.swift | AnySpecificationPerformanceTests.swift |
| Decisions | Core/DecisionSpec.swift, Specs/FirstMatchSpec.swift | DecisionSpecTests.swift, FirstMatchSpecTests.swift |
| Context and providers | Context/EvaluationContext.swift, Context/DefaultContextProvider.swift, Core/ContextProviding.swift | smoke and wrapper tests |
| Built-in rules | Specs/PredicateSpec.swift, MaxCountSpec.swift, time/date specs | date and wrapper tests |
| Async | Core/AsyncSpecification.swift, Wrappers/AsyncSatisfies.swift | AsyncFeaturesTests.swift, AsyncSatisfiesWrapperTests.swift |
| Declarative/macro APIs | Wrappers/*.swift, Definitions/AutoContextSpecification.swift, SpecificationCoreMacros/*.swift | wrapper and macro tests |
Capability Matrix
| Swift capability | Classification | Rust destination | Rationale |
|---|---|---|---|
Specification boolean contract | Adopt | P1-T2 | Core domain abstraction |
Static and, or, not composition | Adopt | P1-T2 | Preserve boolean and short-circuit semantics |
Swift &&, ` | , !` overloads | Adapt | |
PredicateSpec closure wrapper | Adopt | P1-T2 | Implement through a closure-compatible trait or adapter |
| Constants and collection all/any | Adopt | P1-T2 | Preserve logical identities |
AnySpecification | Adapt | P1-T3 | Use explicit Rust trait objects and ownership bounds |
DecisionSpec and boolean-result adapter | Adopt | P1-T3 | Preserve typed optional decision model |
FirstMatchSpec and fallback | Adopt | P1-T3 | Preserve priority and short-circuit behavior |
FirstMatchSpec.Builder | Adapt | P1-T3 | Avoid Swift overload ambiguity |
EvaluationContext counters, flags, events, segments | Adapt | P1-T4 | Preserve concepts with typed/generic data |
[String: Any] user data | Exclude | P1-T4 | Replace with generic user data or deliberate typed values |
| Generic/static context providers | Adapt | P1-T4/P2-T1 | Keep dependency injection with Rust sharing bounds |
| Default provider, locks, Combine updates | Exclude | P2-T1 | Avoid global state and Apple-specific observation |
| Counter and flag rules | Adapt | P1-T4 | Retain semantics after context design is fixed |
| Cooldown, event, and date rules | Defer | P1-T4 | Require clock/time representation policy |
| Property wrappers | Exclude | Migration guide | Rust uses explicit evaluation and Option |
AsyncSpecification and async wrappers | Defer | P2-T2 | Requires runtime-neutral future/object-safety design |
| Swift macros and auto-context | Defer | P2-T3 | Separate procedural macro decision and crate boundary |
| Domain composite examples | Exclude | Examples/docs | Demonstrations, not reusable core API |
| Combine observation | Exclude | None | Platform-specific integration |
| README performance figures | Exclude | P3-T2 | Re-establish with Rust benchmarks |
Behavioral Contract for Rust Tests
| Behavior | Swift evidence | Rust requirement |
|---|---|---|
| AND evaluates left first and skips right after false | Specification.swift | Preserve short-circuiting |
| OR evaluates left first and skips right after true | Specification.swift | Preserve short-circuiting |
| NOT negates its wrapped result | Specification.swift | Preserve boolean complement |
| Empty all-of is true; empty any-of is false | AnySpecification.swift | Preserve logical identities |
| First match wins and later pairs are not evaluated | DecisionSpecTests.swift | Preserve ordered short-circuiting |
| No match produces no result; fallback produces a result | FirstMatchSpecTests.swift | Model as Option and explicit fallback |
| Missing counter is zero; missing flag is false | EvaluationContext.swift | Document and test if this context model is adopted |
MaxCountSpec uses strict < maximum_count | MaxCountSpec.swift | Preserve strict bound |
| Missing cooldown/event timestamp permits evaluation | time specs | Preserve only after clock API exists |
Cooldown threshold uses >= | time specs | Preserve boundary inclusivity |
| Date range is inclusive; missing comparison event is false | date specs | Preserve only after time policy is decided |
| Async evaluation can fail | AsyncSpecification.swift | Preserve error propagation in P2-T2 |
Rust specifications receive candidates by reference unless a later API decision requires ownership. This is an intentional adaptation of Swift’s by-value protocol method and is not a behavioral incompatibility.
P1-T2 Boundary
P1-T2 implements only the synchronous boolean core: trait contract, closure predicates, static composition, constants, collection identities, tests, and documentation. It excludes dynamic dispatch, decisions, context, time, providers, async, wrappers, macros, and domain examples.
Provenance and License
The audited Swift repository declares the MIT License, copyright 2025 Egor Merkushev. This Rust repository also uses MIT. The Rust implementation is an independent rewrite; copied substantial code, prose, or assets must retain the applicable copyright and license notice. Swift package dependencies are not inherited by the Rust core merely because they appear in the Swift manifest.
Relationship to Documentation Migration
The DocC migration map tracks authored documentation destinations. This audit tracks executable behavior and source classification. Both documents must be consulted before a Rust feature is declared compatible with Swift.
Architecture Baseline
The workspace contains a synchronous and runtime-neutral async core library, plus optional procedural-macro and versioned-serialization crates. Each integration may depend on the core, but the dependency direction is never reversed.
See the normative architecture baseline and project charter for the current design contract.