tiny-counter 0.1.0

Track event counts across time windows with fixed memory and fast queries
Documentation
use chrono::{DateTime, Utc};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

use crate::{Error, TimeUnit};

/// Configuration for a time-based interval counter.
///
/// Defines the granularity and retention window for event counting.
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone)]
pub struct IntervalConfig {
    /// Number of buckets to maintain for this interval
    bucket_count: usize,
    /// Time unit for each bucket
    time_unit: TimeUnit,
}

impl IntervalConfig {
    /// Creates a new IntervalConfig with validation.
    ///
    /// # Arguments
    /// * `bucket_count` - Number of buckets to maintain (must be positive)
    /// * `time_unit` - Time unit for each bucket (cannot be `TimeUnit::Ever`)
    ///
    /// # Errors
    /// Returns an error if:
    /// - `bucket_count` is zero
    /// - `time_unit` is `TimeUnit::Ever` (which is a query-time sentinel value)
    #[allow(dead_code)] // Used in tests and available for future programmatic config creation
    pub fn new(bucket_count: usize, time_unit: TimeUnit) -> crate::Result<Self> {
        validate(bucket_count, time_unit)?;
        Ok(Self {
            bucket_count,
            time_unit,
        })
    }

    /// Creates a new IntervalConfig without validation.
    ///
    /// # Safety
    /// This is an internal constructor that bypasses validation.
    /// Caller must ensure bucket_count is positive.
    /// Use only when validation is performed elsewhere (e.g., in EventStoreBuilder::build).
    pub fn new_unchecked(bucket_count: usize, time_unit: TimeUnit) -> Self {
        Self {
            bucket_count,
            time_unit,
        }
    }

    pub(crate) fn validate(&self) -> crate::Result<()> {
        validate(self.bucket_count, self.time_unit)
    }

    /// Returns the number of buckets.
    pub(crate) fn bucket_count(&self) -> usize {
        self.bucket_count
    }

    /// Returns the time unit.
    pub(crate) fn time_unit(&self) -> TimeUnit {
        self.time_unit
    }

    pub(crate) fn first_moment_ever(&self, now: DateTime<Utc>) -> DateTime<Utc> {
        self.time_unit.first_moment_ever(now, self.bucket_count)
    }
}

fn validate(bucket_count: usize, time_unit: TimeUnit) -> crate::Result<()> {
    if bucket_count == 0 {
        return Err(Error::InvalidBucketCount(
            "bucket_count must be positive and non-zero".to_string(),
        ));
    }
    if bucket_count > 100_000 {
        return Err(Error::InvalidBucketCount(
            "bucket_count must be sane".to_string(),
        ));
    }
    if time_unit == TimeUnit::Ever {
        return Err(Error::InvalidTimeUnitEver);
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_interval_config_creation() {
        let config = IntervalConfig::new(256, TimeUnit::Days).unwrap();

        assert_eq!(config.bucket_count(), 256);
        assert_eq!(config.time_unit(), TimeUnit::Days);
    }

    #[test]
    fn test_interval_config_zero_buckets_rejected() {
        let result = IntervalConfig::new(0, TimeUnit::Days);
        assert!(result.is_err());
        if let Err(crate::Error::InvalidBucketCount(msg)) = result {
            assert!(msg.contains("positive"));
        } else {
            panic!("Expected InvalidBucketCount error");
        }
    }

    #[test]
    fn test_interval_config_is_clone() {
        let config = IntervalConfig::new(128, TimeUnit::Hours).unwrap();

        let config2 = config.clone();
        assert_eq!(config.bucket_count(), config2.bucket_count());
        assert_eq!(config.time_unit(), config2.time_unit());
    }

    #[test]
    fn test_interval_config_debug() {
        let config = IntervalConfig::new(100, TimeUnit::Minutes).unwrap();

        let debug_str = format!("{:?}", config);
        assert!(debug_str.contains("bucket_count"));
        assert!(debug_str.contains("time_unit"));
    }

    #[test]
    fn test_interval_config_accessors() {
        let config = IntervalConfig::new(42, TimeUnit::Weeks).unwrap();
        assert_eq!(config.bucket_count(), 42);
        assert_eq!(config.time_unit(), TimeUnit::Weeks);
    }

    #[test]
    fn test_interval_config_rejects_ever() {
        let result = IntervalConfig::new(256, TimeUnit::Ever);
        assert!(result.is_err());
        if let Err(crate::Error::InvalidTimeUnitEver) = result {
            // Expected error type
        } else {
            panic!("Expected InvalidTimeUnitEver error");
        }
    }

    #[test]
    fn test_interval_config_ever_error_message() {
        let result = IntervalConfig::new(256, TimeUnit::Ever);
        assert!(result.is_err());
        let err_msg = result.unwrap_err().to_string();
        assert!(err_msg.contains("TimeUnit::Ever"));
        assert!(err_msg.contains("query-time sentinel"));
    }
}