feat(domain): add ModbusAddress type and HealthStatus enum

Implements T025-T027 from TDD workflow (red-green-refactor):
- T025 (red): Tests for ModbusAddress with From<RelayId> conversion
- T026 (green): ModbusAddress newtype (#[repr(transparent)]) with offset mapping
- T027 (red+green): HealthStatus enum with state transitions

ModbusAddress wraps u16 and converts user-facing relay IDs (1-8) to
Modbus addresses (0-7) at the domain boundary. HealthStatus tracks
relay health with Healthy, Degraded, and Unhealthy states supporting
error tracking and recovery monitoring.

Ref: T025, T026, T027 (specs/001-modbus-relay-control)
This commit is contained in:
2026-01-03 23:44:38 +01:00
parent 6fc1fb834c
commit 7e10823714
5 changed files with 448 additions and 3 deletions

View File

@@ -0,0 +1,295 @@
//! Health monitoring domain module.
//!
//! This module provides health status tracking for the Modbus relay controller.
//! It defines the `HealthStatus` enum which represents the current health state
//! of the system and supports transitions between healthy, degraded, and unhealthy states.
/// Health status of the Modbus relay controller.
///
/// Represents the three possible health states of the system:
/// - `Healthy`: System is operating normally
/// - `Degraded`: System is experiencing errors but still operational
/// - `Unhealthy`: System has critical failures
///
/// # State Transitions
///
/// ```text
/// Healthy ──(errors)──> Degraded ──(more errors)──> Unhealthy
/// ^ | |
/// └──────(recovery)───────┘ |
/// └────────────────(recovery)────────────────────────┘
/// ```
///
/// # Examples
///
/// ```
/// use sta::domain::health::HealthStatus;
///
/// let status = HealthStatus::Healthy;
/// assert!(matches!(status, HealthStatus::Healthy));
///
/// let degraded = HealthStatus::Degraded { consecutive_errors: 3 };
/// assert!(matches!(degraded, HealthStatus::Degraded { .. }));
///
/// let unhealthy = HealthStatus::Unhealthy {
/// reason: "Connection timeout".to_string()
/// };
/// assert!(matches!(unhealthy, HealthStatus::Unhealthy { .. }));
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HealthStatus {
/// System is operating normally with no errors.
Healthy,
/// System is experiencing errors but still operational.
///
/// The `consecutive_errors` field tracks how many errors have occurred
/// without recovery.
Degraded {
/// Number of consecutive errors without recovery.
consecutive_errors: u32,
},
/// System has critical failures and is not operational.
///
/// The `reason` field provides a human-readable description of the failure.
Unhealthy {
/// Human-readable description of the failure reason.
reason: String,
},
}
impl HealthStatus {
/// Creates a new `Healthy` status.
#[must_use]
pub const fn healthy() -> Self {
Self::Healthy
}
/// Creates a new `Degraded` status with the given error count.
#[must_use]
pub const fn degraded(consecutive_errors: u32) -> Self {
Self::Degraded { consecutive_errors }
}
/// Creates a new `Unhealthy` status with the given reason.
#[must_use]
pub fn unhealthy(reason: impl Into<String>) -> Self {
Self::Unhealthy {
reason: reason.into(),
}
}
/// Returns `true` if the status is `Healthy`.
#[must_use]
pub const fn is_healthy(&self) -> bool {
matches!(self, Self::Healthy)
}
/// Returns `true` if the status is `Degraded`.
#[must_use]
pub const fn is_degraded(&self) -> bool {
matches!(self, Self::Degraded { .. })
}
/// Returns `true` if the status is `Unhealthy`.
#[must_use]
pub const fn is_unhealthy(&self) -> bool {
matches!(self, Self::Unhealthy { .. })
}
/// Transitions to a degraded state by incrementing the error count.
///
/// If already degraded, increments the consecutive errors.
/// If healthy, transitions to degraded with 1 error.
/// If unhealthy, remains unhealthy.
#[must_use]
pub fn record_error(self) -> Self {
match self {
Self::Healthy => Self::Degraded { consecutive_errors: 1 },
Self::Degraded { consecutive_errors } => {
Self::Degraded {
consecutive_errors: consecutive_errors + 1,
}
}
Self::Unhealthy { reason } => Self::Unhealthy { reason },
}
}
/// Transitions to a healthy state, resetting all error counts.
#[must_use]
pub fn record_success(self) -> Self {
Self::Healthy
}
/// Transitions to unhealthy state with the given reason.
#[must_use]
pub fn mark_unhealthy(self, reason: impl Into<String>) -> Self {
Self::Unhealthy {
reason: reason.into(),
}
}
}
impl std::fmt::Display for HealthStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Healthy => write!(f, "Healthy"),
Self::Degraded { consecutive_errors } => {
write!(f, "Degraded ({consecutive_errors} consecutive errors)")
}
Self::Unhealthy { reason } => write!(f, "Unhealthy: {reason}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_healthy_status_creation() {
let status = HealthStatus::healthy();
assert!(status.is_healthy());
assert!(!status.is_degraded());
assert!(!status.is_unhealthy());
}
#[test]
fn test_degraded_status_creation() {
let status = HealthStatus::degraded(3);
assert!(!status.is_healthy());
assert!(status.is_degraded());
assert!(!status.is_unhealthy());
assert_eq!(status, HealthStatus::Degraded { consecutive_errors: 3 });
}
#[test]
fn test_unhealthy_status_creation() {
let status = HealthStatus::unhealthy("Connection timeout");
assert!(!status.is_healthy());
assert!(!status.is_degraded());
assert!(status.is_unhealthy());
assert_eq!(
status,
HealthStatus::Unhealthy {
reason: "Connection timeout".to_string()
}
);
}
#[test]
fn test_transition_healthy_to_degraded() {
let status = HealthStatus::healthy();
let status = status.record_error();
assert!(status.is_degraded());
assert_eq!(status, HealthStatus::Degraded { consecutive_errors: 1 });
}
#[test]
fn test_transition_degraded_increments_errors() {
let status = HealthStatus::degraded(2);
let status = status.record_error();
assert!(status.is_degraded());
assert_eq!(status, HealthStatus::Degraded { consecutive_errors: 3 });
}
#[test]
fn test_transition_unhealthy_stays_unhealthy_on_error() {
let status = HealthStatus::unhealthy("Device disconnected");
let status = status.record_error();
assert!(status.is_unhealthy());
assert_eq!(
status,
HealthStatus::Unhealthy {
reason: "Device disconnected".to_string()
}
);
}
#[test]
fn test_transition_healthy_to_unhealthy() {
let status = HealthStatus::healthy();
let status = status.mark_unhealthy("Critical failure");
assert!(status.is_unhealthy());
assert_eq!(
status,
HealthStatus::Unhealthy {
reason: "Critical failure".to_string()
}
);
}
#[test]
fn test_transition_degraded_to_unhealthy() {
let status = HealthStatus::degraded(5);
let status = status.mark_unhealthy("Too many errors");
assert!(status.is_unhealthy());
assert_eq!(
status,
HealthStatus::Unhealthy {
reason: "Too many errors".to_string()
}
);
}
#[test]
fn test_transition_degraded_to_healthy() {
let status = HealthStatus::degraded(3);
let status = status.record_success();
assert!(status.is_healthy());
}
#[test]
fn test_transition_unhealthy_to_healthy() {
let status = HealthStatus::unhealthy("Device offline");
let status = status.record_success();
assert!(status.is_healthy());
}
#[test]
fn test_display_healthy() {
let status = HealthStatus::healthy();
assert_eq!(status.to_string(), "Healthy");
}
#[test]
fn test_display_degraded() {
let status = HealthStatus::degraded(5);
assert_eq!(status.to_string(), "Degraded (5 consecutive errors)");
}
#[test]
fn test_display_unhealthy() {
let status = HealthStatus::unhealthy("Connection lost");
assert_eq!(status.to_string(), "Unhealthy: Connection lost");
}
#[test]
fn test_multiple_transitions() {
// Start healthy
let status = HealthStatus::healthy();
assert!(status.is_healthy());
// Record first error -> Degraded with 1 error
let status = status.record_error();
assert!(status.is_degraded());
assert_eq!(status, HealthStatus::Degraded { consecutive_errors: 1 });
// Record second error -> Degraded with 2 errors
let status = status.record_error();
assert_eq!(status, HealthStatus::Degraded { consecutive_errors: 2 });
// Record third error -> Degraded with 3 errors
let status = status.record_error();
assert_eq!(status, HealthStatus::Degraded { consecutive_errors: 3 });
// Mark unhealthy -> Unhealthy
let status = status.mark_unhealthy("Too many consecutive errors");
assert!(status.is_unhealthy());
// Recover -> Healthy
let status = status.record_success();
assert!(status.is_healthy());
}
}

View File

@@ -36,3 +36,5 @@
//! - Domain specification: `specs/001-modbus-relay-control/spec.md`
pub mod relay;
pub mod modbus;
pub mod health;

View File

@@ -0,0 +1,89 @@
//! Modbus domain module.
//!
//! This module contains Modbus-specific domain types and conversions.
//! It provides a clean abstraction layer between user-facing relay IDs
//! and Modbus protocol addresses.
use super::relay::types::RelayId;
/// Modbus address newtype wrapping u16.
///
/// Represents a Modbus coil address (0-based indexing).
/// User-facing relay IDs (1-8) are converted to Modbus addresses (0-7).
///
/// # Examples
///
/// ```
/// use sta::domain::modbus::ModbusAddress;
/// use sta::domain::relay::types::RelayId;
///
/// let relay_id = RelayId::new(1).unwrap();
/// let modbus_addr = ModbusAddress::from(relay_id);
/// assert_eq!(modbus_addr.as_u16(), 0);
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(transparent)]
pub struct ModbusAddress(u16);
impl ModbusAddress {
/// Returns the inner u16 value.
#[must_use]
pub const fn as_u16(self) -> u16 {
self.0
}
}
/// Converts a user-facing `RelayId` (1-8) to a Modbus address (0-7).
///
/// # Offset Calculation
///
/// Modbus uses 0-based addressing while our user-facing interface uses
/// 1-based relay numbering. This conversion applies the -1 offset.
///
/// # Examples
///
/// ```
/// use sta::domain::modbus::ModbusAddress;
/// use sta::domain::relay::types::RelayId;
///
/// let relay1 = RelayId::new(1).unwrap();
/// assert_eq!(ModbusAddress::from(relay1).as_u16(), 0);
///
/// let relay8 = RelayId::new(8).unwrap();
/// assert_eq!(ModbusAddress::from(relay8).as_u16(), 7);
/// ```
impl From<RelayId> for ModbusAddress {
fn from(relay_id: RelayId) -> Self {
// RelayId is guaranteed to be 1..=8 by its validation
// Convert to 0..=7 for Modbus addressing
Self(u16::from(relay_id.as_u8() - 1))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_relay_id_1_converts_to_modbus_address_0() {
let relay_id = RelayId::new(1).unwrap();
let modbus_addr = ModbusAddress::from(relay_id);
assert_eq!(modbus_addr.as_u16(), 0);
}
#[test]
fn test_relay_id_8_converts_to_modbus_address_7() {
let relay_id = RelayId::new(8).unwrap();
let modbus_addr = ModbusAddress::from(relay_id);
assert_eq!(modbus_addr.as_u16(), 7);
}
#[test]
fn test_all_relay_ids_convert_correctly() {
for i in 1..=8 {
let relay_id = RelayId::new(i).unwrap();
let modbus_addr = ModbusAddress::from(relay_id);
assert_eq!(modbus_addr.as_u16(), u16::from(i - 1));
}
}
}

View File

@@ -1,3 +1,5 @@
use std::fmt::Display;
use thiserror::Error;
/// Human-readable label for a relay.
@@ -45,3 +47,60 @@ impl Default for RelayLabel {
Self(String::from("Unlabeled"))
}
}
impl std::fmt::Display for RelayLabel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_relay_label_new_valid_string() {
// Test: RelayLabel::new("Pump") → Ok
let result = RelayLabel::new("Pump".to_string());
assert!(result.is_ok());
assert_eq!(result.unwrap().as_str(), "Pump");
}
#[test]
fn test_relay_label_new_valid_max_length() {
// Test: RelayLabel::new("A".repeat(50)) → Ok
let label_str = "A".repeat(50);
let result = RelayLabel::new(label_str.clone());
assert!(result.is_ok());
assert_eq!(result.unwrap().as_str(), &label_str);
}
#[test]
fn test_relay_label_new_empty_fails() {
// Test: RelayLabel::new("") → Err(EmptyLabel)
let result = RelayLabel::new(String::new());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), RelayLabelError::Empty));
}
#[test]
fn test_relay_label_new_too_long_fails() {
// Test: RelayLabel::new("A".repeat(51)) → Err(LabelTooLong)
let label_str = "A".repeat(51);
let result = RelayLabel::new(label_str);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), RelayLabelError::TooLong(_)));
}
#[test]
fn test_relay_label_default() {
let label = RelayLabel::default();
assert_eq!(label.as_str(), "Unlabeled");
}
#[test]
fn test_relay_label_display() {
let label = RelayLabel::new("Test Label".to_string()).unwrap();
assert_eq!(format!("{label}"), "Test Label");
}
}

View File

@@ -260,19 +260,19 @@
- **File**: src/domain/relay.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T025** [US1] [TDD] Write tests for ModbusAddress type
- [x] **T025** [US1] [TDD] Write tests for ModbusAddress type
- Test: ModbusAddress::from(RelayId(1)) → ModbusAddress(0)
- Test: ModbusAddress::from(RelayId(8)) → ModbusAddress(7)
- **File**: src/domain/modbus.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T026** [US1] [TDD] Implement ModbusAddress type with From<RelayId>
- [x] **T026** [US1] [TDD] Implement ModbusAddress type with From<RelayId>
- #[repr(transparent)] newtype wrapping u16
- Implement From<RelayId> with offset: user 1-8 → Modbus 0-7
- **File**: src/domain/modbus.rs
- **Complexity**: Low | **Uncertainty**: Low
- [ ] **T027** [US3] [TDD] Write tests and implement HealthStatus enum
- [x] **T027** [US3] [TDD] Write tests and implement HealthStatus enum
- Enum: Healthy, Degraded { consecutive_errors: u32 }, Unhealthy { reason: String }
- Test transitions between states
- **File**: src/domain/health.rs