b948cae269
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
197 lines
6.0 KiB
Rust
197 lines
6.0 KiB
Rust
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<Self> {
|
|
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<Visibility, IllegalTransition> {
|
|
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<String>,
|
|
pub current_location: Option<String>,
|
|
pub current_owner: Option<String>,
|
|
pub recorder: Option<String>,
|
|
pub recording_date: Option<Date>,
|
|
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<String>,
|
|
pub current_location: Option<String>,
|
|
pub current_owner: Option<String>,
|
|
pub recorder: Option<String>,
|
|
pub recording_date: Option<Date>,
|
|
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));
|
|
}
|
|
}
|
|
}
|