haz-domain 0.2.0

Shared domain types for the haz task runner (identifiers, paths, errors).
Documentation
//! Execution-policy workspace settings.
//!
//! Carries the executor's cancellation grace period
//! (`EXEC-014`).

use std::time::Duration;

use snafu::Snafu;

/// Errors produced when validating a [`CancelGrace`] value.
#[derive(Debug, Clone, PartialEq, Snafu)]
pub enum CancelGraceError {
    /// The supplied value was NaN.
    #[snafu(display("cancel_grace is NaN"))]
    Nan,

    /// The supplied value was positive or negative infinity.
    #[snafu(display("cancel_grace is infinite"))]
    Infinite,

    /// The supplied value was negative.
    #[snafu(display("cancel_grace is {value} seconds; must be >= 0"))]
    Negative {
        /// The offending input value.
        value: f64,
    },

    /// The supplied value exceeded the representable [`Duration`]
    /// range.
    #[snafu(display("cancel_grace value {value} seconds exceeds the representable range"))]
    OutOfRange {
        /// The offending input value.
        value: f64,
    },
}

/// Cancellation grace period: how long the executor waits between
/// sending `SIGTERM` and `SIGKILL` to a running task being
/// cancelled (`EXEC-014`).
///
/// Constructed via [`CancelGrace::try_from_secs_f64`] from a
/// non-negative finite number of seconds. `Default::default()`
/// returns the `CFG-031` default of 5 seconds.
///
/// # Examples
///
/// ```
/// use std::time::Duration;
/// use haz_domain::settings::execution::CancelGrace;
///
/// let cg = CancelGrace::default();
/// assert_eq!(cg.as_duration(), Duration::from_secs(5));
///
/// let half = CancelGrace::try_from_secs_f64(0.5).unwrap();
/// assert_eq!(half.as_duration(), Duration::from_millis(500));
///
/// assert!(CancelGrace::try_from_secs_f64(-1.0).is_err());
/// assert!(CancelGrace::try_from_secs_f64(f64::NAN).is_err());
/// assert!(CancelGrace::try_from_secs_f64(f64::INFINITY).is_err());
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CancelGrace(Duration);

impl CancelGrace {
    /// Construct from a non-negative finite seconds value.
    ///
    /// # Errors
    ///
    /// Returns a [`CancelGraceError`] variant if the input is
    /// NaN, infinite, negative, or out of the [`Duration`] range.
    pub fn try_from_secs_f64(secs: f64) -> Result<Self, CancelGraceError> {
        if secs.is_nan() {
            return Err(CancelGraceError::Nan);
        }
        if secs.is_infinite() {
            return Err(CancelGraceError::Infinite);
        }
        if secs < 0.0 {
            return Err(CancelGraceError::Negative { value: secs });
        }
        Duration::try_from_secs_f64(secs)
            .map(Self)
            .map_err(|_| CancelGraceError::OutOfRange { value: secs })
    }

    /// Borrow the wrapped [`Duration`].
    #[must_use]
    pub const fn as_duration(&self) -> Duration {
        self.0
    }
}

impl Default for CancelGrace {
    fn default() -> Self {
        Self(Duration::from_secs(5))
    }
}

/// Workspace-wide execution-policy settings.
///
/// `Default::default()` yields the `CFG-031` default
/// ([`CancelGrace`] of 5 seconds).
///
/// # Examples
///
/// ```
/// use std::time::Duration;
/// use haz_domain::settings::execution::ExecutionSettings;
///
/// let es = ExecutionSettings::default();
/// assert_eq!(es.cancel_grace.as_duration(), Duration::from_secs(5));
/// ```
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct ExecutionSettings {
    /// Cancellation grace period (`EXEC-014`).
    pub cancel_grace: CancelGrace,
}

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use crate::settings::execution::{CancelGrace, CancelGraceError, ExecutionSettings};

    #[test]
    fn default_cancel_grace_is_five_seconds() {
        assert_eq!(CancelGrace::default().as_duration(), Duration::from_secs(5));
    }

    #[test]
    fn try_from_secs_accepts_zero() {
        let cg = CancelGrace::try_from_secs_f64(0.0).unwrap();
        assert_eq!(cg.as_duration(), Duration::ZERO);
    }

    #[test]
    fn try_from_secs_accepts_fractional() {
        let cg = CancelGrace::try_from_secs_f64(0.5).unwrap();
        assert_eq!(cg.as_duration(), Duration::from_millis(500));
    }

    #[test]
    fn try_from_secs_accepts_integer_form() {
        let cg = CancelGrace::try_from_secs_f64(10.0).unwrap();
        assert_eq!(cg.as_duration(), Duration::from_secs(10));
    }

    #[test]
    fn try_from_secs_rejects_nan() {
        assert!(matches!(
            CancelGrace::try_from_secs_f64(f64::NAN),
            Err(CancelGraceError::Nan)
        ));
    }

    #[test]
    fn try_from_secs_rejects_positive_infinity() {
        assert!(matches!(
            CancelGrace::try_from_secs_f64(f64::INFINITY),
            Err(CancelGraceError::Infinite)
        ));
    }

    #[test]
    fn try_from_secs_rejects_negative_infinity() {
        assert!(matches!(
            CancelGrace::try_from_secs_f64(f64::NEG_INFINITY),
            Err(CancelGraceError::Infinite)
        ));
    }

    #[test]
    fn try_from_secs_rejects_negative() {
        match CancelGrace::try_from_secs_f64(-1.0) {
            Err(CancelGraceError::Negative { value }) => {
                assert!((value - -1.0).abs() < f64::EPSILON);
            }
            other => panic!("expected Negative, got {other:?}"),
        }
    }

    #[test]
    fn try_from_secs_rejects_overflow() {
        // Far above Duration::MAX (~ u64::MAX seconds, ~1.84e19).
        let huge = 1.0e20_f64;
        assert!(matches!(
            CancelGrace::try_from_secs_f64(huge),
            Err(CancelGraceError::OutOfRange { .. })
        ));
    }

    #[test]
    fn execution_settings_default_uses_default_cancel_grace() {
        assert_eq!(
            ExecutionSettings::default().cancel_grace.as_duration(),
            Duration::from_secs(5)
        );
    }
}