alma 0.1.1

A Bevy-native modal text editor with Vim-style navigation.
Documentation
use crate::plugin::{PluginRuntimeLimitError, PluginRuntimeLimitField};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::num::{NonZeroU64, NonZeroUsize};

/// Maximum linear memory bytes accepted for one guest instance.
pub const MAX_PLUGIN_MEMORY_BYTES: u64 = 256 * 1024 * 1024;
/// Maximum host-call payload bytes accepted from one guest call.
pub const MAX_PLUGIN_MESSAGE_BYTES: u64 = 8 * 1024 * 1024;
/// Maximum fuel units accepted for one guest update.
pub const MAX_PLUGIN_FUEL_PER_UPDATE: u64 = 100_000_000;
/// Maximum wall-clock milliseconds accepted for one guest update.
pub const MAX_PLUGIN_TIMEOUT_MS: u64 = 1_000;
/// Maximum sealed effect batches accepted for ECS publication.
pub const MAX_PLUGIN_INTENT_BATCHES: u64 = 1_024;

/// Serializable guest execution limits.
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct PluginRuntimeLimits {
    /// Maximum linear memory bytes per guest instance.
    pub max_memory_bytes: u64,
    /// Maximum host-call payload bytes.
    pub max_message_bytes: u64,
    /// Maximum fuel units per guest update.
    pub fuel_per_update: u64,
    /// Maximum wall-clock milliseconds per guest update.
    pub timeout_ms: u64,
    /// Maximum sealed effect batches awaiting ECS publication.
    pub max_intent_batches: u64,
}

impl PluginRuntimeLimits {
    /// Creates validated runtime limits.
    ///
    /// # Errors
    ///
    /// Returns [`PluginRuntimeLimitError`] when a limit is zero or above its host cap.
    pub fn try_new(
        max_memory_bytes: u64,
        max_message_bytes: u64,
        fuel_per_update: u64,
        timeout_ms: u64,
    ) -> Result<Self, PluginRuntimeLimitError> {
        let limits = Self {
            max_memory_bytes,
            max_message_bytes,
            fuel_per_update,
            timeout_ms,
            max_intent_batches: Self::default().max_intent_batches,
        };
        let _validated = limits.validate()?;
        Ok(limits)
    }

    /// Validates limits before runtime use.
    ///
    /// # Errors
    ///
    /// Returns [`PluginRuntimeLimitError`] when a limit is zero or above its host cap.
    pub fn validate(self) -> Result<ValidatedPluginRuntimeLimits, PluginRuntimeLimitError> {
        ValidatedPluginRuntimeLimits::try_from_config(self)
    }
}

impl Default for PluginRuntimeLimits {
    fn default() -> Self {
        Self {
            max_memory_bytes: 16 * 1024 * 1024,
            max_message_bytes: 1024 * 1024,
            fuel_per_update: 1_000_000,
            timeout_ms: 50,
            max_intent_batches: MAX_PLUGIN_INTENT_BATCHES,
        }
    }
}

/// Runtime limits after host cap validation.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ValidatedPluginRuntimeLimits {
    /// Validated linear memory cap.
    max_memory_bytes: NonZeroU64,
    /// Validated host-call payload cap.
    max_message_bytes: NonZeroU64,
    /// Validated fuel cap.
    fuel_per_update: NonZeroU64,
    /// Validated wall-clock cap.
    timeout_ms: NonZeroU64,
    /// Validated host queue cap.
    max_intent_batches: NonZeroUsize,
}

impl ValidatedPluginRuntimeLimits {
    /// Validates runtime limits from static config.
    ///
    /// # Errors
    ///
    /// Returns [`PluginRuntimeLimitError`] when a limit is zero or above its host cap.
    pub fn try_from_config(limits: PluginRuntimeLimits) -> Result<Self, PluginRuntimeLimitError> {
        let max_memory_bytes = validate_limit(
            PluginRuntimeLimitField::MaxMemoryBytes,
            limits.max_memory_bytes,
            MAX_PLUGIN_MEMORY_BYTES,
        )?;
        let max_message_bytes = validate_limit(
            PluginRuntimeLimitField::MaxMessageBytes,
            limits.max_message_bytes,
            MAX_PLUGIN_MESSAGE_BYTES,
        )?;
        let fuel_per_update = validate_limit(
            PluginRuntimeLimitField::FuelPerUpdate,
            limits.fuel_per_update,
            MAX_PLUGIN_FUEL_PER_UPDATE,
        )?;
        let timeout_ms = validate_limit(
            PluginRuntimeLimitField::TimeoutMs,
            limits.timeout_ms,
            MAX_PLUGIN_TIMEOUT_MS,
        )?;
        let max_intent_batches = validate_host_count_limit(
            PluginRuntimeLimitField::MaxIntentBatches,
            limits.max_intent_batches,
            MAX_PLUGIN_INTENT_BATCHES,
        )?;
        Ok(Self {
            max_memory_bytes,
            max_message_bytes,
            fuel_per_update,
            timeout_ms,
            max_intent_batches,
        })
    }

    /// Returns the validated config shape.
    #[must_use]
    pub const fn as_config(self) -> PluginRuntimeLimits {
        PluginRuntimeLimits {
            max_memory_bytes: self.max_memory_bytes.get(),
            max_message_bytes: self.max_message_bytes.get(),
            fuel_per_update: self.fuel_per_update.get(),
            timeout_ms: self.timeout_ms.get(),
            max_intent_batches: self.max_intent_batches.get() as u64,
        }
    }

    /// Returns the maximum guest linear memory bytes.
    #[must_use]
    pub const fn max_memory_bytes(self) -> u64 {
        self.max_memory_bytes.get()
    }

    /// Returns the maximum host-call payload bytes.
    #[must_use]
    pub const fn max_message_bytes(self) -> u64 {
        self.max_message_bytes.get()
    }

    /// Returns the host-call payload cap as a non-zero proof.
    #[must_use]
    pub(in crate::plugin) const fn max_message_byte_limit(self) -> NonZeroU64 {
        self.max_message_bytes
    }

    /// Returns the per-update fuel cap.
    #[must_use]
    pub const fn fuel_per_update(self) -> u64 {
        self.fuel_per_update.get()
    }

    /// Returns the wall-clock cap in milliseconds.
    #[must_use]
    pub const fn timeout_ms(self) -> u64 {
        self.timeout_ms.get()
    }

    /// Returns the host-sized sealed-batch queue cap.
    #[must_use]
    pub const fn max_intent_batches(self) -> usize {
        self.max_intent_batches.get()
    }
}

impl Default for ValidatedPluginRuntimeLimits {
    fn default() -> Self {
        PluginRuntimeLimits::default()
            .validate()
            .expect("default plugin runtime limits should validate")
    }
}

/// Validates one non-zero bounded limit.
const fn validate_limit(
    field: PluginRuntimeLimitField,
    value: u64,
    max: u64,
) -> Result<NonZeroU64, PluginRuntimeLimitError> {
    let Some(value) = NonZeroU64::new(value) else {
        return Err(PluginRuntimeLimitError::Zero { field });
    };
    if value.get() > max {
        return Err(PluginRuntimeLimitError::TooLarge {
            field,
            value: value.get(),
            max,
        });
    }
    Ok(value)
}

/// Validates a runtime limit that becomes a host collection cap.
fn validate_host_count_limit(
    field: PluginRuntimeLimitField,
    value: u64,
    max: u64,
) -> Result<NonZeroUsize, PluginRuntimeLimitError> {
    let value = validate_limit(field, value, max)?;
    let value =
        usize::try_from(value.get()).expect("validated plugin host count limit should fit usize");
    Ok(NonZeroUsize::new(value).expect("validated plugin host count limit should be non-zero"))
}

#[cfg(test)]
mod tests {
    use super::{
        MAX_PLUGIN_FUEL_PER_UPDATE, MAX_PLUGIN_INTENT_BATCHES, MAX_PLUGIN_MEMORY_BYTES,
        MAX_PLUGIN_MESSAGE_BYTES, MAX_PLUGIN_TIMEOUT_MS, PluginRuntimeLimitField,
        PluginRuntimeLimits,
    };
    use crate::plugin::PluginRuntimeLimitError;
    use proptest::prelude::*;

    proptest! {
        #[test]
        fn runtime_limits_reject_zero_values(field in runtime_limit_field_strategy()) {
            prop_assert_eq!(
                field.with_value(0).validate(),
                Err(PluginRuntimeLimitError::Zero {
                    field,
                })
            );
        }

        #[test]
        fn runtime_limits_reject_values_above_host_caps(
            field in runtime_limit_field_strategy(),
            excess in 1_u64..=1024,
        ) {
            let value = field.max().saturating_add(excess);

            prop_assert_eq!(
                field.with_value(value).validate(),
                Err(PluginRuntimeLimitError::TooLarge {
                    field,
                    value,
                    max: field.max(),
                })
            );
        }

        #[test]
        fn runtime_limits_accept_nonzero_values_under_host_caps(
            max_memory_bytes in 1_u64..=MAX_PLUGIN_MEMORY_BYTES,
            max_message_bytes in 1_u64..=MAX_PLUGIN_MESSAGE_BYTES,
            fuel_per_update in 1_u64..=MAX_PLUGIN_FUEL_PER_UPDATE,
            timeout_ms in 1_u64..=MAX_PLUGIN_TIMEOUT_MS,
            max_intent_batches in 1_u64..=MAX_PLUGIN_INTENT_BATCHES,
        ) {
            let limits = PluginRuntimeLimits {
                max_memory_bytes,
                max_message_bytes,
                fuel_per_update,
                timeout_ms,
                max_intent_batches,
            };

            let validated = limits.validate().expect("generated limits should validate");
            prop_assert_eq!(validated.as_config(), limits);
            prop_assert_eq!(validated.max_memory_bytes(), max_memory_bytes);
            prop_assert_eq!(validated.max_message_bytes(), max_message_bytes);
            prop_assert_eq!(validated.fuel_per_update(), fuel_per_update);
            prop_assert_eq!(validated.timeout_ms(), timeout_ms);
            prop_assert_eq!(
                validated.max_intent_batches(),
                usize::try_from(max_intent_batches)
                    .expect("validated intent batch limit should fit usize")
            );
        }

        #[test]
        fn runtime_limits_try_new_preserves_public_constructor_invariants(
            max_memory_bytes in 1_u64..=MAX_PLUGIN_MEMORY_BYTES,
            max_message_bytes in 1_u64..=MAX_PLUGIN_MESSAGE_BYTES,
            fuel_per_update in 1_u64..=MAX_PLUGIN_FUEL_PER_UPDATE,
            timeout_ms in 1_u64..=MAX_PLUGIN_TIMEOUT_MS,
        ) {
            let limits = PluginRuntimeLimits::try_new(
                max_memory_bytes,
                max_message_bytes,
                fuel_per_update,
                timeout_ms,
            )
            .expect("generated limits should validate");

            prop_assert_eq!(limits.max_memory_bytes, max_memory_bytes);
            prop_assert_eq!(limits.max_message_bytes, max_message_bytes);
            prop_assert_eq!(limits.fuel_per_update, fuel_per_update);
            prop_assert_eq!(limits.timeout_ms, timeout_ms);
            prop_assert_eq!(
                limits.max_intent_batches,
                PluginRuntimeLimits::default().max_intent_batches
            );
            prop_assert_eq!(limits.validate().expect("limits should revalidate").as_config(), limits);
        }
    }

    #[test]
    fn runtime_limit_fields_render_stable_text() {
        assert_eq!(
            PluginRuntimeLimitField::MaxMemoryBytes.as_str(),
            "max_memory_bytes"
        );
        assert_eq!(
            PluginRuntimeLimitField::MaxMessageBytes.to_string(),
            "max_message_bytes"
        );
        assert_eq!(
            PluginRuntimeLimitField::FuelPerUpdate.to_string(),
            "fuel_per_update"
        );
        assert_eq!(PluginRuntimeLimitField::TimeoutMs.to_string(), "timeout_ms");
        assert_eq!(
            PluginRuntimeLimitField::MaxIntentBatches.to_string(),
            "max_intent_batches"
        );
    }

    impl PluginRuntimeLimitField {
        const fn max(self) -> u64 {
            match self {
                Self::MaxMemoryBytes => MAX_PLUGIN_MEMORY_BYTES,
                Self::MaxMessageBytes => MAX_PLUGIN_MESSAGE_BYTES,
                Self::FuelPerUpdate => MAX_PLUGIN_FUEL_PER_UPDATE,
                Self::TimeoutMs => MAX_PLUGIN_TIMEOUT_MS,
                Self::MaxIntentBatches => MAX_PLUGIN_INTENT_BATCHES,
            }
        }

        fn with_value(self, value: u64) -> PluginRuntimeLimits {
            match self {
                Self::MaxMemoryBytes => PluginRuntimeLimits {
                    max_memory_bytes: value,
                    ..PluginRuntimeLimits::default()
                },
                Self::MaxMessageBytes => PluginRuntimeLimits {
                    max_message_bytes: value,
                    ..PluginRuntimeLimits::default()
                },
                Self::FuelPerUpdate => PluginRuntimeLimits {
                    fuel_per_update: value,
                    ..PluginRuntimeLimits::default()
                },
                Self::TimeoutMs => PluginRuntimeLimits {
                    timeout_ms: value,
                    ..PluginRuntimeLimits::default()
                },
                Self::MaxIntentBatches => PluginRuntimeLimits {
                    max_intent_batches: value,
                    ..PluginRuntimeLimits::default()
                },
            }
        }
    }

    fn runtime_limit_field_strategy() -> impl Strategy<Value = PluginRuntimeLimitField> {
        prop_oneof![
            Just(PluginRuntimeLimitField::MaxMemoryBytes),
            Just(PluginRuntimeLimitField::MaxMessageBytes),
            Just(PluginRuntimeLimitField::FuelPerUpdate),
            Just(PluginRuntimeLimitField::TimeoutMs),
            Just(PluginRuntimeLimitField::MaxIntentBatches),
        ]
    }
}