rustledger_loader/phase.rs
1//! Phantom-typed phase markers for the directive-processing pipeline.
2//!
3//! The loader runs directives through a strict sequence of phases:
4//!
5//! ```text
6//! Raw → Sorted → Synthed → EarlyValidated → Booked
7//! → RegularPluginsApplied → LateValidated → Finalized
8//! ```
9//!
10//! Phase ordering was previously enforced by code organization and
11//! inline comments in `process.rs`. This module makes the ordering
12//! a property of the type system: each phase transition consumes a
13//! [`Directives<P>`] of one phase and produces one of the next phase
14//! only. A refactor that drops a phase, swaps two phases, or calls a
15//! later phase on raw input produces a type error rather than silent
16//! misbehavior. See issue #1166.
17//!
18//! ## Phase definitions
19//!
20//! | Phase | Invariant after this phase |
21//! |---|---|
22//! | [`Raw`] | Straight from the parser. No ordering / synth / booking guarantees. |
23//! | [`Sorted`] | Sorted into canonical display order `(date, priority, file_id, span.start)`. |
24//! | [`Synthed`] | Synth-only plugins (`auto_accounts`, `document_discovery`) applied. |
25//! | [`EarlyValidated`] | Early-phase validators ran. Account presence / lifecycle / structural errors collected. |
26//! | [`Booked`] | Cost-spec interpolation done. Failed transactions partitioned out. |
27//! | [`RegularPluginsApplied`] | Post-booking plugins (cost-reading) applied to the successfully-booked directives. |
28//! | [`LateValidated`] | Late-phase validators ran on booked + plugin-processed directives. |
29//! | [`Finalized`] | Failed transactions re-merged + re-sorted into the final display order. |
30//!
31//! ## Why a phantom rather than separate Vec types?
32//!
33//! The underlying payload is `Vec<Spanned<Directive>>` in every phase
34//! — only the *invariants* differ, not the layout. Phantom-data
35//! markers carry the phase at the type level without changing the
36//! runtime representation. rkyv cache compatibility is preserved
37//! (the wrapper is zero-sized in memory).
38//!
39//! ## Booking partition note
40//!
41//! `Directives<Booked>` carries only the successfully-booked
42//! transactions. Failed ones are returned in a `FailedBookings`
43//! newtype (an internal `pub(crate)` wrapper around
44//! `Vec<Spanned<Directive>>`) and re-merged at [`Finalized`] (see
45//! the `book` and `finalize` transitions in `process.rs`). The
46//! newtype gives the out-of-band channel a name and a type — the
47//! `finalize` call can't accidentally receive an arbitrary
48//! `Vec<Spanned<Directive>>` (e.g. a freshly-parsed one). The
49//! phantom-typed `Directives<P>` can't express "this Vec holds a
50//! mix of stages," which is why the failed branch travels alongside
51//! the main pipeline rather than as another phase.
52//!
53//! ## Open design choices documented in #1166
54//!
55//! - **Error state is NOT carried in the phase type.** Phase tracks
56//! ordering; errors accumulate in a separate `Vec<LedgerError>`
57//! passed through the pipeline. Including the error state in the
58//! phase would explode the variant count and impede the chain.
59//! - **Only [`Finalized`] is exposed publicly.** Pipeline methods
60//! are `pub(crate)`; downstream consumers can't accidentally hold
61//! a partially-processed `Directives` because the only escape
62//! hatch is `Directives<Finalized>::into_inner()`.
63//! - **Plugin trait is NOT stage-parameterized in this PR.** Plugins
64//! continue to use the `PluginPass` enum to discriminate
65//! synth-vs-regular. Making the trait phase-aware is a follow-up;
66//! the current approach catches the call-site error (calling the
67//! wrong phase function) without restructuring the plugin API.
68
69use std::marker::PhantomData;
70
71use rustledger_core::Directive;
72use rustledger_parser::Spanned;
73
74mod sealed {
75 pub trait Sealed {}
76}
77
78/// Marker trait for pipeline phases. Sealed: only the markers in
79/// this module implement it, so downstream crates can't invent new
80/// phases (which would defeat the type-driven ordering).
81pub trait Phase: sealed::Sealed {}
82
83macro_rules! define_phase {
84 ($name:ident, $doc:expr) => {
85 #[doc = $doc]
86 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
87 pub struct $name;
88 impl sealed::Sealed for $name {}
89 impl Phase for $name {}
90 };
91}
92
93define_phase!(
94 Raw,
95 "Straight from the parser — no ordering, synth, or booking guarantees."
96);
97define_phase!(
98 Sorted,
99 "Sorted by `(date, priority, file_id, span.start)` — canonical display order."
100);
101define_phase!(
102 Synthed,
103 "Synth-only plugins (`auto_accounts`, `document_discovery`) applied."
104);
105define_phase!(
106 EarlyValidated,
107 "Early validators ran; account-presence / lifecycle / structural errors collected."
108);
109define_phase!(
110 Booked,
111 "Cost-spec interpolation done; failed transactions partitioned out-of-band."
112);
113define_phase!(
114 RegularPluginsApplied,
115 "Post-booking plugins applied to successfully-booked directives."
116);
117define_phase!(
118 LateValidated,
119 "Late-phase validators ran on booked + plugin-processed directives."
120);
121define_phase!(
122 Finalized,
123 "Failed transactions re-merged + re-sorted into the final display order."
124);
125
126/// A directive collection at a specific pipeline phase.
127///
128/// The phase is a phantom marker — the runtime representation is the
129/// same `Vec<Spanned<Directive>>` regardless of `P`. Transitions
130/// between phases are the only way to advance: see the `impl`
131/// blocks in `process.rs` for each phase's allowed next step.
132///
133/// Constructed only via [`Directives::from_parser`] (which produces
134/// [`Directives<Raw>`]). Subsequent phases are reached by calling
135/// the relevant transition methods in order.
136#[derive(Debug)]
137pub struct Directives<P: Phase> {
138 inner: Vec<Spanned<Directive>>,
139 _phase: PhantomData<P>,
140}
141
142impl<P: Phase> Directives<P> {
143 /// **Internal**: construct a `Directives<P>` from a raw `Vec`.
144 /// Phase transitions use this to advance the phantom. External
145 /// callers must enter the pipeline via [`Directives::from_parser`]
146 /// (which produces [`Directives<Raw>`]). Kept `const` because
147 /// `from_parser` is `pub const fn` and chains through here.
148 pub(crate) const fn new_unchecked(inner: Vec<Spanned<Directive>>) -> Self {
149 Self {
150 inner,
151 _phase: PhantomData,
152 }
153 }
154
155 /// Read-only borrow of the underlying directive slice.
156 #[must_use]
157 pub const fn as_slice(&self) -> &[Spanned<Directive>] {
158 self.inner.as_slice()
159 }
160
161 /// Mutable borrow of the underlying directive vec.
162 ///
163 /// Pipeline transitions hand this to subsystems (booker,
164 /// validator, plugin runner) that mutate in place. The phase
165 /// invariant is the *next* phase's contract — mutation that
166 /// breaks the invariant of the current phase is the caller's
167 /// responsibility (and is normally confined to the transition
168 /// function itself).
169 pub(crate) const fn as_vec_mut(&mut self) -> &mut Vec<Spanned<Directive>> {
170 &mut self.inner
171 }
172
173 /// Number of directives in the collection.
174 #[must_use]
175 pub const fn len(&self) -> usize {
176 self.inner.len()
177 }
178
179 /// Whether the collection is empty.
180 #[must_use]
181 pub const fn is_empty(&self) -> bool {
182 self.inner.is_empty()
183 }
184}
185
186impl Directives<Raw> {
187 /// Entry point into the pipeline: wrap a parser-produced
188 /// directive list as [`Directives<Raw>`]. The only public
189 /// constructor — every other phase is reachable only via
190 /// transitions from a prior phase.
191 #[must_use]
192 pub const fn from_parser(directives: Vec<Spanned<Directive>>) -> Self {
193 Self::new_unchecked(directives)
194 }
195}
196
197/// Transactions that failed booking, partitioned out of the main
198/// pipeline by the `book` transition on [`Directives<EarlyValidated>`]
199/// and re-merged at the `finalize` transition on
200/// [`Directives<LateValidated>`].
201///
202/// Effectively `pub(crate)`: the only producer (`book`) and consumer
203/// (`finalize`) are both crate-internal, and the type lives in the
204/// private `mod phase` of `rustledger-loader`, so external callers
205/// can't get a value of this type. Kept as a named newtype rather
206/// than a bare `Vec` so `finalize` can't accidentally receive an
207/// arbitrary directive list at the call site. The contents are
208/// pre-booking shape: unresolved cost specs, unfilled elided slots,
209/// possibly unbalanced.
210#[derive(Debug)]
211pub struct FailedBookings {
212 inner: Vec<Spanned<Directive>>,
213}
214
215impl FailedBookings {
216 /// **Internal**: construct from a raw `Vec`. The `book` transition
217 /// is the only legitimate producer.
218 ///
219 /// `pub` only because the type lives in a private module — there's
220 /// no path from the crate root to `FailedBookings::new`, so
221 /// external code can't call it.
222 pub const fn new(inner: Vec<Spanned<Directive>>) -> Self {
223 Self { inner }
224 }
225
226 /// Consume and return the underlying directives.
227 /// Used by `finalize` to merge them back into the display order.
228 //
229 // Not `const fn`: `self` has a destructor (the `Vec` field), and
230 // E0493 forbids running destructors at compile-time. Clippy's
231 // `missing_const_for_fn` knows this and correctly doesn't fire,
232 // so no `#[allow]`/`#[expect]` is needed.
233 pub fn into_inner(self) -> Vec<Spanned<Directive>> {
234 self.inner
235 }
236}
237
238impl Directives<Finalized> {
239 /// Exit point: consume the finalized collection and return the
240 /// underlying `Vec`. The only way to extract `Vec<Spanned<Directive>>`
241 /// from the pipeline. Downstream code (`Ledger.directives`) only
242 /// sees fully-processed output.
243 //
244 // Same rationale as above for not being `const fn`.
245 #[must_use]
246 pub fn into_inner(self) -> Vec<Spanned<Directive>> {
247 self.inner
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn directives_raw_can_be_constructed_from_parser_output() {
257 // The entry-point contract: from_parser is the only way to
258 // get a `Directives<Raw>`. (Compile-fail tests for other
259 // phases live as `compile_fail` doctests where appropriate;
260 // here we just verify the happy path.)
261 let raw = Directives::<Raw>::from_parser(Vec::new());
262 assert_eq!(raw.len(), 0);
263 assert!(raw.is_empty());
264 }
265
266 #[test]
267 fn finalized_into_inner_returns_the_vec() {
268 // Exit-point contract: into_inner consumes the wrapper and
269 // returns the bare Vec. Used by the Ledger constructor.
270 let finalized = Directives::<Finalized>::new_unchecked(Vec::new());
271 let v = finalized.into_inner();
272 assert_eq!(v.len(), 0);
273 }
274}