Skip to main content

elevator_core/
host_label.rs

1//! Sealed kebab-case label vocabulary for cross-host serialisation.
2//!
3//! Hosts (FFI / wasm / gdext / future) all need to render core enums as
4//! short string labels — `"idle"`, `"normal"`, `"up"` — and parse them
5//! back. Letting each host coin its own labels would let them drift, so
6//! this module is the single source of truth.
7//!
8//! The label vocabulary is part of every host's public contract: changing
9//! a label string is a breaking change for downstream consumers
10//! (TypeScript bindings, Godot scripts, GameMaker projects, …). New
11//! variants on `#[non_exhaustive]` core enums must add a label here in
12//! the same release; the formatters fall back to a stable default
13//! (`"unknown"`, `"out-of-service"`, `"either"`) for unrecognised
14//! variants so a missing label does not panic the host.
15//!
16//! Mirrors the `host_error::ErrorKind` cross-host vocabulary work.
17
18#![allow(unreachable_patterns)]
19// `Variant | _ =>` arms below are the standard pattern for `#[non_exhaustive]`
20// enums: defensive within the same crate (where rustc can prove `_` is
21// unreachable today), load-bearing across crates (where downstream consumers
22// would need it). `unreachable_patterns` fires on the in-crate side; the
23// allow is the cleanest way to keep the cross-crate-safe shape.
24
25use crate::components::{CallDirection, Direction, ElevatorPhase, ServiceMode};
26
27/// Format an [`ElevatorPhase`] as a kebab-case label suitable for CSS
28/// class names, debug overlays, and serialised host events.
29///
30/// Falls back to `"unknown"` for variants this module has not been
31/// updated to cover.
32#[must_use]
33pub const fn elevator_phase(phase: ElevatorPhase) -> &'static str {
34    match phase {
35        ElevatorPhase::Idle => "idle",
36        ElevatorPhase::MovingToStop(_) => "moving",
37        ElevatorPhase::Repositioning(_) => "repositioning",
38        ElevatorPhase::DoorOpening => "door-opening",
39        ElevatorPhase::Loading => "loading",
40        ElevatorPhase::DoorClosing => "door-closing",
41        ElevatorPhase::Stopped => "stopped",
42        _ => "unknown",
43    }
44}
45
46/// Format a [`ServiceMode`] as a kebab-case label.
47///
48/// `ServiceMode` is `#[non_exhaustive]`; new variants without a label
49/// here surface as `"out-of-service"` rather than panicking. Add a
50/// label in the same release that adds the variant.
51#[must_use]
52pub const fn service_mode(mode: ServiceMode) -> &'static str {
53    match mode {
54        ServiceMode::Normal => "normal",
55        ServiceMode::Independent => "independent",
56        ServiceMode::Inspection => "inspection",
57        ServiceMode::Manual => "manual",
58        ServiceMode::OutOfService | _ => "out-of-service",
59    }
60}
61
62/// Inverse of [`service_mode`]: parse a kebab-case label back to a
63/// [`ServiceMode`]. Unknown labels return `None`.
64#[must_use]
65pub fn parse_service_mode(label: &str) -> Option<ServiceMode> {
66    match label {
67        "normal" => Some(ServiceMode::Normal),
68        "independent" => Some(ServiceMode::Independent),
69        "inspection" => Some(ServiceMode::Inspection),
70        "manual" => Some(ServiceMode::Manual),
71        "out-of-service" => Some(ServiceMode::OutOfService),
72        _ => None,
73    }
74}
75
76/// Format a [`Direction`] as `"up"` / `"down"` / `"either"`.
77///
78/// `Direction` is `#[non_exhaustive]`; unrecognised variants fall back
79/// to `"either"`.
80#[must_use]
81pub const fn direction(dir: Direction) -> &'static str {
82    match dir {
83        Direction::Up => "up",
84        Direction::Down => "down",
85        Direction::Either | _ => "either",
86    }
87}
88
89/// Parse a kebab-case label into a [`CallDirection`].
90///
91/// Only `"up"` and `"down"` are valid hall-call directions; everything
92/// else returns an error message embedding the offending input.
93///
94/// # Errors
95///
96/// Returns `Err` with a descriptive message if the label is neither
97/// `"up"` nor `"down"`.
98pub fn parse_call_direction(label: &str) -> Result<CallDirection, String> {
99    match label {
100        "up" => Ok(CallDirection::Up),
101        "down" => Ok(CallDirection::Down),
102        other => Err(format!("direction must be 'up' or 'down', got {other:?}")),
103    }
104}
105
106#[cfg(test)]
107#[allow(clippy::expect_used)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn elevator_phase_label_covers_every_variant() {
113        // The labels are part of the host contract; rely on a literal
114        // table here rather than building it dynamically so a renamed
115        // variant fails the test loudly. There is no `parse_elevator_phase`
116        // inverse, so this is a one-way label table check (not a round-trip).
117        assert_eq!(elevator_phase(ElevatorPhase::Idle), "idle");
118        assert_eq!(
119            elevator_phase(ElevatorPhase::MovingToStop(
120                crate::entity::EntityId::default()
121            )),
122            "moving"
123        );
124        assert_eq!(
125            elevator_phase(ElevatorPhase::Repositioning(
126                crate::entity::EntityId::default()
127            )),
128            "repositioning"
129        );
130        assert_eq!(elevator_phase(ElevatorPhase::DoorOpening), "door-opening");
131        assert_eq!(elevator_phase(ElevatorPhase::Loading), "loading");
132        assert_eq!(elevator_phase(ElevatorPhase::DoorClosing), "door-closing");
133        assert_eq!(elevator_phase(ElevatorPhase::Stopped), "stopped");
134    }
135
136    #[test]
137    fn service_mode_round_trips() {
138        for mode in [
139            ServiceMode::Normal,
140            ServiceMode::Independent,
141            ServiceMode::Inspection,
142            ServiceMode::Manual,
143            ServiceMode::OutOfService,
144        ] {
145            let label = service_mode(mode);
146            assert_eq!(
147                parse_service_mode(label),
148                Some(mode),
149                "round-trip failed for {mode:?} via label {label:?}",
150            );
151        }
152    }
153
154    #[test]
155    fn parse_service_mode_rejects_unknown() {
156        assert_eq!(parse_service_mode(""), None);
157        assert_eq!(parse_service_mode("Normal"), None); // case-sensitive
158        assert_eq!(parse_service_mode("offline"), None);
159    }
160
161    #[test]
162    fn direction_label_strings() {
163        assert_eq!(direction(Direction::Up), "up");
164        assert_eq!(direction(Direction::Down), "down");
165        assert_eq!(direction(Direction::Either), "either");
166    }
167
168    #[test]
169    fn parse_call_direction_accepts_only_up_and_down() {
170        assert_eq!(parse_call_direction("up").expect("ok"), CallDirection::Up);
171        assert_eq!(
172            parse_call_direction("down").expect("ok"),
173            CallDirection::Down
174        );
175        assert!(parse_call_direction("either").is_err());
176        assert!(parse_call_direction("UP").is_err());
177        assert!(parse_call_direction("").is_err());
178    }
179}