use serde::{Deserialize, Serialize}; use time::{Date, OffsetDateTime}; use crate::ObjectId; /// Publication state of a catalogue record. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum Visibility { /// Work in progress; not shown anywhere public. #[default] Draft, /// Complete but internal-only. Internal, /// Published; eligible for the public API. Public, } impl Visibility { pub const fn as_str(&self) -> &'static str { match self { Visibility::Draft => "draft", Visibility::Internal => "internal", Visibility::Public => "public", } } pub fn from_db(s: &str) -> Option { match s { "draft" => Some(Visibility::Draft), "internal" => Some(Visibility::Internal), "public" => Some(Visibility::Public), _ => None, } } } impl Visibility { /// Whether `self` may move directly to `target`. Legal single steps are /// `draft↔internal` and `internal↔public`; `draft↔public` is not one step. pub fn can_transition_to(self, target: Visibility) -> bool { use Visibility::*; matches!( (self, target), (Draft, Internal) | (Internal, Draft) | (Internal, Public) | (Public, Internal) ) } /// Validate a stepwise transition to `target`. Setting to the current value is an /// idempotent no-op (`Ok`). A forbidden jump returns [`IllegalTransition`]. pub fn transition_to(self, target: Visibility) -> Result { if self == target || self.can_transition_to(target) { Ok(target) } else { Err(IllegalTransition { from: self, to: target, }) } } } /// An attempted visibility change the state machine forbids. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct IllegalTransition { pub from: Visibility, pub to: Visibility, } impl std::fmt::Display for IllegalTransition { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "illegal visibility transition: {} -> {}", self.from.as_str(), self.to.as_str() ) } } impl std::error::Error for IllegalTransition {} /// The mutable inventory-minimum fields of a catalogue object. #[derive(Debug, Clone, PartialEq)] pub struct ObjectInput { pub object_number: String, pub object_name: String, pub number_of_objects: i32, pub brief_description: Option, pub current_location: Option, pub current_owner: Option, pub recorder: Option, pub recording_date: Option, pub visibility: Visibility, } /// A catalogue object (or group of objects), read back from storage. #[derive(Debug, Clone, PartialEq)] pub struct CatalogueObject { pub id: ObjectId, pub object_number: String, pub object_name: String, pub number_of_objects: i32, pub brief_description: Option, pub current_location: Option, pub current_owner: Option, pub recorder: Option, pub recording_date: Option, pub visibility: Visibility, /// Flexible field values (field key -> value), validated against the registry. pub fields: serde_json::Value, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } impl CatalogueObject { /// The mutable fields as an [`ObjectInput`] (used to diff against an update). pub fn to_input(&self) -> ObjectInput { ObjectInput { object_number: self.object_number.clone(), object_name: self.object_name.clone(), number_of_objects: self.number_of_objects, brief_description: self.brief_description.clone(), current_location: self.current_location.clone(), current_owner: self.current_owner.clone(), recorder: self.recorder.clone(), recording_date: self.recording_date, visibility: self.visibility, } } } #[cfg(test)] mod tests { use super::*; #[test] fn visibility_round_trips_and_defaults_to_draft() { for v in [Visibility::Draft, Visibility::Internal, Visibility::Public] { assert_eq!(Visibility::from_db(v.as_str()), Some(v)); } assert_eq!(Visibility::from_db("secret"), None); assert_eq!(Visibility::default(), Visibility::Draft); } #[test] fn visibility_serde_matches_as_str() { for v in [Visibility::Draft, Visibility::Internal, Visibility::Public] { assert_eq!( serde_json::to_value(v).unwrap(), serde_json::Value::String(v.as_str().to_owned()) ); } } #[test] fn stepwise_transitions_are_legal() { use Visibility::*; assert_eq!(Draft.transition_to(Internal), Ok(Internal)); assert_eq!(Internal.transition_to(Public), Ok(Public)); assert_eq!(Public.transition_to(Internal), Ok(Internal)); assert_eq!(Internal.transition_to(Draft), Ok(Draft)); } #[test] fn skipping_a_step_is_illegal() { use Visibility::*; assert_eq!( Draft.transition_to(Public), Err(IllegalTransition { from: Draft, to: Public }) ); assert_eq!( Public.transition_to(Draft), Err(IllegalTransition { from: Public, to: Draft }) ); // the Display message is the user-visible surface of the error assert_eq!( Draft.transition_to(Public).unwrap_err().to_string(), "illegal visibility transition: draft -> public" ); } #[test] fn setting_to_current_value_is_a_noop_ok() { for v in [Visibility::Draft, Visibility::Internal, Visibility::Public] { assert_eq!(v.transition_to(v), Ok(v)); } } }