dnspls-core 0.1.0

Provider-neutral evidence and decision kernel for DNSpls
Documentation
use std::{collections::BTreeSet, error::Error, fmt};

use serde::{Deserialize, Serialize};

/// Transport-independent lifecycle of a bounded verification batch.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BatchState {
    Planned,
    Running,
    Complete,
    Partial,
    Cancelled,
    Expired,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Completion {
    Succeeded,
    Failed,
}

/// A small deterministic state machine; async orchestration owns no state rules.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct VerificationBatch {
    expected: u32,
    succeeded: BTreeSet<u32>,
    failed: BTreeSet<u32>,
    state: BatchState,
}

impl VerificationBatch {
    pub const fn planned(expected: u32) -> Self {
        Self {
            expected,
            succeeded: BTreeSet::new(),
            failed: BTreeSet::new(),
            state: BatchState::Planned,
        }
    }

    /// Starts a planned batch, completing an empty batch immediately.
    ///
    /// # Errors
    ///
    /// Returns [`VerificationTransitionError::InvalidState`] unless planned.
    pub fn start(&mut self) -> Result<(), VerificationTransitionError> {
        if self.state != BatchState::Planned {
            return Err(VerificationTransitionError::InvalidState);
        }
        if self.expected == 0 {
            self.state = BatchState::Complete;
        } else {
            self.state = BatchState::Running;
        }
        Ok(())
    }

    /// Records exactly one terminal result for an input position.
    ///
    /// # Errors
    ///
    /// Rejects invalid state, out-of-range indices, and duplicate completion.
    pub fn record(
        &mut self,
        index: u32,
        completion: Completion,
    ) -> Result<(), VerificationTransitionError> {
        if self.state != BatchState::Running {
            return Err(VerificationTransitionError::InvalidState);
        }
        if index >= self.expected {
            return Err(VerificationTransitionError::IndexOutOfBounds);
        }
        if self.succeeded.contains(&index) || self.failed.contains(&index) {
            return Err(VerificationTransitionError::DuplicateCompletion);
        }

        match completion {
            Completion::Succeeded => self.succeeded.insert(index),
            Completion::Failed => self.failed.insert(index),
        };
        if self.completed() == self.expected {
            self.state = if self.failed.is_empty() {
                BatchState::Complete
            } else {
                BatchState::Partial
            };
        }
        Ok(())
    }

    /// Cancels a running batch.
    ///
    /// # Errors
    ///
    /// Returns [`VerificationTransitionError::InvalidState`] unless running.
    pub fn cancel(&mut self) -> Result<(), VerificationTransitionError> {
        self.finish_early(BatchState::Cancelled)
    }

    /// Expires a running batch at its external deadline.
    ///
    /// # Errors
    ///
    /// Returns [`VerificationTransitionError::InvalidState`] unless running.
    pub fn expire(&mut self) -> Result<(), VerificationTransitionError> {
        self.finish_early(BatchState::Expired)
    }

    pub const fn state(&self) -> BatchState {
        self.state
    }

    pub const fn expected(&self) -> u32 {
        self.expected
    }

    pub fn completed(&self) -> u32 {
        u32::try_from(self.succeeded.len() + self.failed.len()).unwrap_or(self.expected)
    }

    pub fn succeeded_indices(&self) -> &BTreeSet<u32> {
        &self.succeeded
    }

    pub fn failed_indices(&self) -> &BTreeSet<u32> {
        &self.failed
    }

    fn finish_early(&mut self, terminal: BatchState) -> Result<(), VerificationTransitionError> {
        if self.state != BatchState::Running {
            return Err(VerificationTransitionError::InvalidState);
        }
        self.state = terminal;
        Ok(())
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VerificationTransitionError {
    InvalidState,
    IndexOutOfBounds,
    DuplicateCompletion,
}

impl fmt::Display for VerificationTransitionError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        let message = match self {
            Self::InvalidState => "verification transition is invalid for the current state",
            Self::IndexOutOfBounds => "verification result index is outside the batch",
            Self::DuplicateCompletion => "verification result index was already completed",
        };
        formatter.write_str(message)
    }
}

impl Error for VerificationTransitionError {}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn batch_reaches_complete_only_when_every_item_succeeds() {
        let mut batch = VerificationBatch::planned(2);
        batch.start().unwrap();
        batch.record(1, Completion::Succeeded).unwrap();
        assert_eq!(batch.state(), BatchState::Running);
        batch.record(0, Completion::Succeeded).unwrap();
        assert_eq!(batch.state(), BatchState::Complete);
    }

    #[test]
    fn failure_is_partial_and_duplicate_completion_is_rejected() {
        let mut batch = VerificationBatch::planned(2);
        batch.start().unwrap();
        batch.record(0, Completion::Failed).unwrap();
        assert_eq!(
            batch.record(0, Completion::Succeeded),
            Err(VerificationTransitionError::DuplicateCompletion)
        );
        batch.record(1, Completion::Succeeded).unwrap();
        assert_eq!(batch.state(), BatchState::Partial);
    }

    #[test]
    fn terminal_states_cannot_be_reopened() {
        let mut batch = VerificationBatch::planned(1);
        batch.start().unwrap();
        batch.cancel().unwrap();
        assert_eq!(
            batch.start(),
            Err(VerificationTransitionError::InvalidState)
        );
        assert_eq!(
            batch.expire(),
            Err(VerificationTransitionError::InvalidState)
        );
    }
}