use crate::error::TransitionError;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, strum::Display,
)]
pub enum ResourceState {
Pending,
Provisioning,
Running,
Updating,
Deleting,
Deleted,
Failed,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
serde::Serialize,
serde::Deserialize,
strum::Display,
strum::EnumIter,
)]
pub enum ResourceEvent {
ProvisionStarted,
ProvisionCompleted,
UpdateRequested,
UpdateCompleted,
DeleteRequested,
DeleteCompleted,
OperationFailed,
RetryRequested,
}
impl ResourceState {
pub fn transition(self, event: ResourceEvent) -> Result<Self, TransitionError> {
match (self, event) {
(Self::Pending, ResourceEvent::ProvisionStarted) => Ok(Self::Provisioning),
(Self::Provisioning, ResourceEvent::ProvisionCompleted)
| (Self::Updating, ResourceEvent::UpdateCompleted) => Ok(Self::Running),
(Self::Provisioning | Self::Updating, ResourceEvent::OperationFailed) => {
Ok(Self::Failed)
}
(Self::Running, ResourceEvent::UpdateRequested) => Ok(Self::Updating),
(Self::Pending | Self::Running | Self::Failed, ResourceEvent::DeleteRequested) => {
Ok(Self::Deleting)
}
(Self::Deleting, ResourceEvent::DeleteCompleted) => Ok(Self::Deleted),
(Self::Failed, ResourceEvent::RetryRequested) => Ok(Self::Pending),
_ => Err(TransitionError::new(self.to_string(), event.to_string())),
}
}
#[must_use]
pub const fn accepts_mutations(&self) -> bool {
matches!(self, Self::Running)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ResourceMetadata {
id: uuid::Uuid,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
impl ResourceMetadata {
#[must_use]
pub fn new() -> Self {
let now = chrono::Utc::now();
Self {
id: uuid::Uuid::new_v4(),
created_at: now,
updated_at: now,
}
}
#[must_use]
pub const fn id(&self) -> uuid::Uuid {
self.id
}
#[must_use]
pub const fn created_at(&self) -> chrono::DateTime<chrono::Utc> {
self.created_at
}
#[must_use]
pub const fn updated_at(&self) -> chrono::DateTime<chrono::Utc> {
self.updated_at
}
}
impl Default for ResourceMetadata {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use strum::IntoEnumIterator;
use super::*;
#[test]
fn metadata_new_generates_uuid_and_timestamps() {
let meta = ResourceMetadata::new();
assert!(!meta.id().is_nil());
assert_eq!(meta.created_at(), meta.updated_at());
}
#[test]
fn metadata_fields_are_consistent() {
let meta = ResourceMetadata::new();
assert!(!meta.id().is_nil());
assert_eq!(meta.created_at(), meta.updated_at());
let cloned = meta.clone();
assert_eq!(meta, cloned);
}
#[test]
fn pending_to_provisioning_on_provision_started() {
let next = ResourceState::Pending
.transition(ResourceEvent::ProvisionStarted)
.expect("should succeed");
assert_eq!(next, ResourceState::Provisioning);
}
#[test]
fn pending_to_deleting_on_delete_requested() {
let next = ResourceState::Pending
.transition(ResourceEvent::DeleteRequested)
.expect("should succeed");
assert_eq!(next, ResourceState::Deleting);
}
#[test]
fn provisioning_to_running_on_provision_completed() {
let next = ResourceState::Provisioning
.transition(ResourceEvent::ProvisionCompleted)
.expect("should succeed");
assert_eq!(next, ResourceState::Running);
}
#[test]
fn provisioning_to_failed_on_operation_failed() {
let next = ResourceState::Provisioning
.transition(ResourceEvent::OperationFailed)
.expect("should succeed");
assert_eq!(next, ResourceState::Failed);
}
#[test]
fn running_to_updating_on_update_requested() {
let next = ResourceState::Running
.transition(ResourceEvent::UpdateRequested)
.expect("should succeed");
assert_eq!(next, ResourceState::Updating);
}
#[test]
fn running_to_deleting_on_delete_requested() {
let next = ResourceState::Running
.transition(ResourceEvent::DeleteRequested)
.expect("should succeed");
assert_eq!(next, ResourceState::Deleting);
}
#[test]
fn updating_to_running_on_update_completed() {
let next = ResourceState::Updating
.transition(ResourceEvent::UpdateCompleted)
.expect("should succeed");
assert_eq!(next, ResourceState::Running);
}
#[test]
fn updating_to_failed_on_operation_failed() {
let next = ResourceState::Updating
.transition(ResourceEvent::OperationFailed)
.expect("should succeed");
assert_eq!(next, ResourceState::Failed);
}
#[test]
fn deleting_to_deleted_on_delete_completed() {
let next = ResourceState::Deleting
.transition(ResourceEvent::DeleteCompleted)
.expect("should succeed");
assert_eq!(next, ResourceState::Deleted);
}
#[test]
fn failed_to_pending_on_retry_requested() {
let next = ResourceState::Failed
.transition(ResourceEvent::RetryRequested)
.expect("should succeed");
assert_eq!(next, ResourceState::Pending);
}
#[test]
fn failed_to_deleting_on_delete_requested() {
let next = ResourceState::Failed
.transition(ResourceEvent::DeleteRequested)
.expect("should succeed");
assert_eq!(next, ResourceState::Deleting);
}
#[test]
fn full_happy_lifecycle() {
let state = ResourceState::Pending;
let state = state
.transition(ResourceEvent::ProvisionStarted)
.expect("Pending → Provisioning");
let state = state
.transition(ResourceEvent::ProvisionCompleted)
.expect("Provisioning → Running");
let state = state
.transition(ResourceEvent::DeleteRequested)
.expect("Running → Deleting");
let state = state
.transition(ResourceEvent::DeleteCompleted)
.expect("Deleting → Deleted");
assert_eq!(state, ResourceState::Deleted);
}
#[test]
fn update_cycle() {
let state = ResourceState::Running;
let state = state
.transition(ResourceEvent::UpdateRequested)
.expect("Running → Updating");
let state = state
.transition(ResourceEvent::UpdateCompleted)
.expect("Updating → Running");
assert_eq!(state, ResourceState::Running);
}
#[test]
fn retry_after_failure() {
let state = ResourceState::Failed;
let state = state
.transition(ResourceEvent::RetryRequested)
.expect("Failed → Pending");
let state = state
.transition(ResourceEvent::ProvisionStarted)
.expect("Pending → Provisioning");
let state = state
.transition(ResourceEvent::ProvisionCompleted)
.expect("Provisioning → Running");
assert_eq!(state, ResourceState::Running);
}
#[test]
fn pending_rejects_provision_completed() {
assert!(
ResourceState::Pending
.transition(ResourceEvent::ProvisionCompleted)
.is_err()
);
}
#[test]
fn pending_rejects_update_requested() {
assert!(
ResourceState::Pending
.transition(ResourceEvent::UpdateRequested)
.is_err()
);
}
#[test]
fn pending_rejects_update_completed() {
assert!(
ResourceState::Pending
.transition(ResourceEvent::UpdateCompleted)
.is_err()
);
}
#[test]
fn pending_rejects_operation_failed() {
assert!(
ResourceState::Pending
.transition(ResourceEvent::OperationFailed)
.is_err()
);
}
#[test]
fn pending_rejects_retry_requested() {
assert!(
ResourceState::Pending
.transition(ResourceEvent::RetryRequested)
.is_err()
);
}
#[test]
fn provisioning_rejects_update_requested() {
assert!(
ResourceState::Provisioning
.transition(ResourceEvent::UpdateRequested)
.is_err()
);
}
#[test]
fn provisioning_rejects_delete_requested() {
assert!(
ResourceState::Provisioning
.transition(ResourceEvent::DeleteRequested)
.is_err()
);
}
#[test]
fn running_rejects_provision_started() {
assert!(
ResourceState::Running
.transition(ResourceEvent::ProvisionStarted)
.is_err()
);
}
#[test]
fn running_rejects_provision_completed() {
assert!(
ResourceState::Running
.transition(ResourceEvent::ProvisionCompleted)
.is_err()
);
}
#[test]
fn running_rejects_retry_requested() {
assert!(
ResourceState::Running
.transition(ResourceEvent::RetryRequested)
.is_err()
);
}
#[test]
fn updating_rejects_provision_started() {
assert!(
ResourceState::Updating
.transition(ResourceEvent::ProvisionStarted)
.is_err()
);
}
#[test]
fn updating_rejects_delete_requested() {
assert!(
ResourceState::Updating
.transition(ResourceEvent::DeleteRequested)
.is_err()
);
}
#[test]
fn deleting_rejects_everything_except_delete_completed() {
let non_delete_events =
ResourceEvent::iter().filter(|e| *e != ResourceEvent::DeleteCompleted);
for event in non_delete_events {
assert!(
ResourceState::Deleting.transition(event).is_err(),
"Deleting should reject {event}"
);
}
}
#[test]
fn deleted_rejects_all_events() {
for event in ResourceEvent::iter() {
assert!(
ResourceState::Deleted.transition(event).is_err(),
"Deleted should reject {event}"
);
}
}
#[test]
fn failed_rejects_provision_started() {
assert!(
ResourceState::Failed
.transition(ResourceEvent::ProvisionStarted)
.is_err()
);
}
#[test]
fn failed_rejects_update_requested() {
assert!(
ResourceState::Failed
.transition(ResourceEvent::UpdateRequested)
.is_err()
);
}
#[test]
fn transition_error_contains_state_and_event() {
let err = ResourceState::Running
.transition(ResourceEvent::ProvisionStarted)
.expect_err("should fail");
assert_eq!(err.from_state(), "Running");
assert_eq!(err.event(), "ProvisionStarted");
}
#[test]
fn only_running_accepts_mutations() {
assert!(ResourceState::Running.accepts_mutations());
assert!(!ResourceState::Pending.accepts_mutations());
assert!(!ResourceState::Provisioning.accepts_mutations());
assert!(!ResourceState::Updating.accepts_mutations());
assert!(!ResourceState::Deleting.accepts_mutations());
assert!(!ResourceState::Deleted.accepts_mutations());
assert!(!ResourceState::Failed.accepts_mutations());
}
}