Skip to main content

dnspls_core/
verification.rs

1use std::{collections::BTreeSet, error::Error, fmt};
2
3use serde::{Deserialize, Serialize};
4
5/// Transport-independent lifecycle of a bounded verification batch.
6#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum BatchState {
9    Planned,
10    Running,
11    Complete,
12    Partial,
13    Cancelled,
14    Expired,
15}
16
17#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum Completion {
20    Succeeded,
21    Failed,
22}
23
24/// A small deterministic state machine; async orchestration owns no state rules.
25#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
26pub struct VerificationBatch {
27    expected: u32,
28    succeeded: BTreeSet<u32>,
29    failed: BTreeSet<u32>,
30    state: BatchState,
31}
32
33impl VerificationBatch {
34    pub const fn planned(expected: u32) -> Self {
35        Self {
36            expected,
37            succeeded: BTreeSet::new(),
38            failed: BTreeSet::new(),
39            state: BatchState::Planned,
40        }
41    }
42
43    /// Starts a planned batch, completing an empty batch immediately.
44    ///
45    /// # Errors
46    ///
47    /// Returns [`VerificationTransitionError::InvalidState`] unless planned.
48    pub fn start(&mut self) -> Result<(), VerificationTransitionError> {
49        if self.state != BatchState::Planned {
50            return Err(VerificationTransitionError::InvalidState);
51        }
52        if self.expected == 0 {
53            self.state = BatchState::Complete;
54        } else {
55            self.state = BatchState::Running;
56        }
57        Ok(())
58    }
59
60    /// Records exactly one terminal result for an input position.
61    ///
62    /// # Errors
63    ///
64    /// Rejects invalid state, out-of-range indices, and duplicate completion.
65    pub fn record(
66        &mut self,
67        index: u32,
68        completion: Completion,
69    ) -> Result<(), VerificationTransitionError> {
70        if self.state != BatchState::Running {
71            return Err(VerificationTransitionError::InvalidState);
72        }
73        if index >= self.expected {
74            return Err(VerificationTransitionError::IndexOutOfBounds);
75        }
76        if self.succeeded.contains(&index) || self.failed.contains(&index) {
77            return Err(VerificationTransitionError::DuplicateCompletion);
78        }
79
80        match completion {
81            Completion::Succeeded => self.succeeded.insert(index),
82            Completion::Failed => self.failed.insert(index),
83        };
84        if self.completed() == self.expected {
85            self.state = if self.failed.is_empty() {
86                BatchState::Complete
87            } else {
88                BatchState::Partial
89            };
90        }
91        Ok(())
92    }
93
94    /// Cancels a running batch.
95    ///
96    /// # Errors
97    ///
98    /// Returns [`VerificationTransitionError::InvalidState`] unless running.
99    pub fn cancel(&mut self) -> Result<(), VerificationTransitionError> {
100        self.finish_early(BatchState::Cancelled)
101    }
102
103    /// Expires a running batch at its external deadline.
104    ///
105    /// # Errors
106    ///
107    /// Returns [`VerificationTransitionError::InvalidState`] unless running.
108    pub fn expire(&mut self) -> Result<(), VerificationTransitionError> {
109        self.finish_early(BatchState::Expired)
110    }
111
112    pub const fn state(&self) -> BatchState {
113        self.state
114    }
115
116    pub const fn expected(&self) -> u32 {
117        self.expected
118    }
119
120    pub fn completed(&self) -> u32 {
121        u32::try_from(self.succeeded.len() + self.failed.len()).unwrap_or(self.expected)
122    }
123
124    pub fn succeeded_indices(&self) -> &BTreeSet<u32> {
125        &self.succeeded
126    }
127
128    pub fn failed_indices(&self) -> &BTreeSet<u32> {
129        &self.failed
130    }
131
132    fn finish_early(&mut self, terminal: BatchState) -> Result<(), VerificationTransitionError> {
133        if self.state != BatchState::Running {
134            return Err(VerificationTransitionError::InvalidState);
135        }
136        self.state = terminal;
137        Ok(())
138    }
139}
140
141#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum VerificationTransitionError {
144    InvalidState,
145    IndexOutOfBounds,
146    DuplicateCompletion,
147}
148
149impl fmt::Display for VerificationTransitionError {
150    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
151        let message = match self {
152            Self::InvalidState => "verification transition is invalid for the current state",
153            Self::IndexOutOfBounds => "verification result index is outside the batch",
154            Self::DuplicateCompletion => "verification result index was already completed",
155        };
156        formatter.write_str(message)
157    }
158}
159
160impl Error for VerificationTransitionError {}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn batch_reaches_complete_only_when_every_item_succeeds() {
168        let mut batch = VerificationBatch::planned(2);
169        batch.start().unwrap();
170        batch.record(1, Completion::Succeeded).unwrap();
171        assert_eq!(batch.state(), BatchState::Running);
172        batch.record(0, Completion::Succeeded).unwrap();
173        assert_eq!(batch.state(), BatchState::Complete);
174    }
175
176    #[test]
177    fn failure_is_partial_and_duplicate_completion_is_rejected() {
178        let mut batch = VerificationBatch::planned(2);
179        batch.start().unwrap();
180        batch.record(0, Completion::Failed).unwrap();
181        assert_eq!(
182            batch.record(0, Completion::Succeeded),
183            Err(VerificationTransitionError::DuplicateCompletion)
184        );
185        batch.record(1, Completion::Succeeded).unwrap();
186        assert_eq!(batch.state(), BatchState::Partial);
187    }
188
189    #[test]
190    fn terminal_states_cannot_be_reopened() {
191        let mut batch = VerificationBatch::planned(1);
192        batch.start().unwrap();
193        batch.cancel().unwrap();
194        assert_eq!(
195            batch.start(),
196            Err(VerificationTransitionError::InvalidState)
197        );
198        assert_eq!(
199            batch.expire(),
200            Err(VerificationTransitionError::InvalidState)
201        );
202    }
203}