tiny-counter 0.1.0

Track event counts across time windows with fixed memory and fast queries
Documentation
use std::collections::HashSet;

use chrono::{DateTime, Utc};

#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

use crate::{IntervalConfig, SingleEventCounter, TimeUnit};

/// Configuration for an EventStore, defining which time intervals to track.
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone)]
pub struct EventCounterConfig {
    configs: Vec<IntervalConfig>,
}

impl PartialEq for EventCounterConfig {
    fn eq(&self, other: &Self) -> bool {
        // Check count matches
        if self.configs.len() != other.configs.len() {
            return false;
        }

        // Check each config in self exists in other with same bucket count
        // Order doesn't matter, so we check for presence
        for self_config in &self.configs {
            match other
                .configs
                .iter()
                .find(|c| c.time_unit() == self_config.time_unit())
            {
                Some(other_config) => {
                    if self_config.bucket_count() != other_config.bucket_count() {
                        return false;
                    }
                }
                None => return false,
            }
        }

        true
    }
}

impl EventCounterConfig {
    /// Creates a new EventCounterConfig with custom interval configurations.
    ///
    /// # Panics
    /// Panics if duplicate TimeUnits are provided.
    pub fn new(configs: Vec<IntervalConfig>) -> Self {
        // Validate no duplicate TimeUnits
        let mut seen_units = HashSet::new();
        for config in &configs {
            let unit = config.time_unit();
            if !seen_units.insert(unit) {
                panic!("Duplicate TimeUnit in EventCounterConfig: {:?}", unit);
            }
        }

        let mut configs = configs;
        configs.sort_by_key(|c| c.time_unit());

        Self { configs }
    }

    /// Creates a new EventCounterConfig from a slice of interval configurations.
    ///
    /// # Panics
    /// Panics if duplicate TimeUnits are provided.
    #[allow(dead_code)] // Convenience constructor for future use
    pub(crate) fn from_intervals(configs: &[IntervalConfig]) -> Self {
        Self::new(configs.to_vec())
    }

    /// Creates a new SingleEventCounter with this configuration.
    pub fn create_counter(&self, starting_instant: DateTime<Utc>) -> SingleEventCounter {
        SingleEventCounter::new(self, starting_instant)
    }

    /// Gets the IntervalConfig for a specific TimeUnit.
    /// Returns None if the time unit is not configured.
    #[allow(dead_code)]
    pub(crate) fn get(&self, time_unit: TimeUnit) -> Option<&IntervalConfig> {
        self.configs
            .iter()
            .find(|config| config.time_unit() == time_unit)
    }

    /// Returns an iterator over configured time units.
    #[allow(dead_code)]
    pub(crate) fn time_units(&self) -> impl Iterator<Item = TimeUnit> + '_ {
        self.configs.iter().map(|config| config.time_unit())
    }

    /// Returns a slice of configs in ascending order (Minutes → Years).
    pub(crate) fn configs(&self) -> &[IntervalConfig] {
        &self.configs
    }

    /// Returns configs as a Vec (for conversion/merge operations).
    pub(crate) fn as_vec(&self) -> Vec<IntervalConfig> {
        self.configs.clone()
    }

    /// Returns the number of configured intervals.
    #[allow(dead_code)]
    pub(crate) fn len(&self) -> usize {
        self.configs.len()
    }

    pub(crate) fn specified_time_unit(&self, time_unit: TimeUnit) -> TimeUnit {
        match time_unit {
            TimeUnit::Ever => self.longest_time_unit().unwrap_or(TimeUnit::Days),
            _ => time_unit,
        }
    }

    /// Returns the longest time unit configured for this counter.
    ///
    /// Returns None if the counter has no intervals (should never happen in practice).
    pub(crate) fn longest_time_unit(&self) -> Option<TimeUnit> {
        self.configs.iter().map(|c| c.time_unit()).max()
    }
}

impl Default for EventCounterConfig {
    /// Returns the default configuration: Minutes(60), Hours(72), Days(56),
    /// Weeks(52), Months(12), Years(4).
    fn default() -> Self {
        Self::new(vec![
            IntervalConfig::new_unchecked(60, TimeUnit::Minutes),
            IntervalConfig::new_unchecked(72, TimeUnit::Hours),
            IntervalConfig::new_unchecked(56, TimeUnit::Days),
            IntervalConfig::new_unchecked(52, TimeUnit::Weeks),
            IntervalConfig::new_unchecked(12, TimeUnit::Months),
            IntervalConfig::new_unchecked(4, TimeUnit::Years),
        ])
    }
}

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

    impl EventCounterConfig {
        fn empty() -> Self {
            Self::new(Vec::default())
        }
    }

    #[test]
    fn test_new_stores_configs_in_vec() {
        let config = EventCounterConfig::new(vec![
            IntervalConfig::new_unchecked(60, TimeUnit::Minutes),
            IntervalConfig::new_unchecked(72, TimeUnit::Hours),
        ]);
        assert_eq!(config.len(), 2);
    }

    #[test]
    fn test_get_finds_by_time_unit() {
        let config = EventCounterConfig::new(vec![
            IntervalConfig::new_unchecked(60, TimeUnit::Minutes),
            IntervalConfig::new_unchecked(72, TimeUnit::Hours),
        ]);

        let minutes = config.get(TimeUnit::Minutes);
        assert!(minutes.is_some());
        assert_eq!(minutes.unwrap().bucket_count(), 60);

        let hours = config.get(TimeUnit::Hours);
        assert!(hours.is_some());
        assert_eq!(hours.unwrap().bucket_count(), 72);

        let days = config.get(TimeUnit::Days);
        assert!(days.is_none());
    }

    #[test]
    fn test_as_vec_clones_internal_vec() {
        let config = EventCounterConfig::new(vec![
            IntervalConfig::new_unchecked(60, TimeUnit::Minutes),
            IntervalConfig::new_unchecked(72, TimeUnit::Hours),
        ]);

        let vec = config.as_vec();
        assert_eq!(vec.len(), 2);
    }

    #[test]
    fn test_time_units_iterator() {
        let config = EventCounterConfig::new(vec![
            IntervalConfig::new_unchecked(60, TimeUnit::Minutes),
            IntervalConfig::new_unchecked(72, TimeUnit::Hours),
            IntervalConfig::new_unchecked(56, TimeUnit::Days),
        ]);

        let units: Vec<TimeUnit> = config.time_units().collect();
        assert_eq!(units.len(), 3);
        assert!(units.contains(&TimeUnit::Minutes));
        assert!(units.contains(&TimeUnit::Hours));
        assert!(units.contains(&TimeUnit::Days));
    }

    #[test]
    #[should_panic(expected = "Duplicate TimeUnit")]
    fn test_new_panics_on_duplicate_time_units() {
        EventCounterConfig::new(vec![
            IntervalConfig::new_unchecked(60, TimeUnit::Minutes),
            IntervalConfig::new_unchecked(120, TimeUnit::Minutes), // Duplicate!
        ]);
    }

    #[test]
    fn test_default_config_has_six_intervals() {
        let config = EventCounterConfig::default();
        assert_eq!(config.len(), 6);
    }

    #[test]
    fn test_empty_creates_config_with_no_intervals() {
        let config = EventCounterConfig::empty();
        assert_eq!(config.len(), 0);
        assert!(config.get(TimeUnit::Minutes).is_none());
        assert!(config.get(TimeUnit::Hours).is_none());
    }

    #[test]
    fn test_empty_config_as_vec_is_empty() {
        let config = EventCounterConfig::empty();
        let vec = config.as_vec();
        assert_eq!(vec.len(), 0);
    }

    #[test]
    fn test_from_intervals_creates_config_from_slice() {
        let intervals = [
            IntervalConfig::new_unchecked(60, TimeUnit::Minutes),
            IntervalConfig::new_unchecked(72, TimeUnit::Hours),
        ];
        let config = EventCounterConfig::from_intervals(&intervals);
        assert_eq!(config.len(), 2);
        assert!(config.get(TimeUnit::Minutes).is_some());
        assert!(config.get(TimeUnit::Hours).is_some());
    }

    #[test]
    fn test_from_intervals_empty_slice() {
        let intervals: [IntervalConfig; 0] = [];
        let config = EventCounterConfig::from_intervals(&intervals);
        assert_eq!(config.len(), 0);
    }

    #[test]
    #[should_panic(expected = "Duplicate TimeUnit")]
    fn test_from_intervals_panics_on_duplicates() {
        let intervals = [
            IntervalConfig::new_unchecked(60, TimeUnit::Minutes),
            IntervalConfig::new_unchecked(120, TimeUnit::Minutes), // Duplicate!
        ];
        EventCounterConfig::from_intervals(&intervals);
    }

    #[test]
    fn test_partial_eq_same_configs_same_order() {
        let config1 = EventCounterConfig::new(vec![
            IntervalConfig::new_unchecked(60, TimeUnit::Minutes),
            IntervalConfig::new_unchecked(72, TimeUnit::Hours),
        ]);
        let config2 = EventCounterConfig::new(vec![
            IntervalConfig::new_unchecked(60, TimeUnit::Minutes),
            IntervalConfig::new_unchecked(72, TimeUnit::Hours),
        ]);
        assert_eq!(config1, config2);
    }

    #[test]
    fn test_partial_eq_same_configs_different_order() {
        let config1 = EventCounterConfig::new(vec![
            IntervalConfig::new_unchecked(60, TimeUnit::Minutes),
            IntervalConfig::new_unchecked(72, TimeUnit::Hours),
        ]);
        let config2 = EventCounterConfig::new(vec![
            IntervalConfig::new_unchecked(72, TimeUnit::Hours),
            IntervalConfig::new_unchecked(60, TimeUnit::Minutes),
        ]);
        assert_eq!(config1, config2);
    }

    #[test]
    fn test_partial_eq_different_bucket_counts() {
        let config1 = EventCounterConfig::new(vec![
            IntervalConfig::new_unchecked(60, TimeUnit::Minutes),
            IntervalConfig::new_unchecked(72, TimeUnit::Hours),
        ]);
        let config2 = EventCounterConfig::new(vec![
            IntervalConfig::new_unchecked(60, TimeUnit::Minutes),
            IntervalConfig::new_unchecked(24, TimeUnit::Hours), // Different bucket count
        ]);
        assert_ne!(config1, config2);
    }

    #[test]
    fn test_partial_eq_different_time_units() {
        let config1 = EventCounterConfig::new(vec![
            IntervalConfig::new_unchecked(60, TimeUnit::Minutes),
            IntervalConfig::new_unchecked(72, TimeUnit::Hours),
        ]);
        let config2 = EventCounterConfig::new(vec![
            IntervalConfig::new_unchecked(60, TimeUnit::Minutes),
            IntervalConfig::new_unchecked(56, TimeUnit::Days), // Different time unit
        ]);
        assert_ne!(config1, config2);
    }

    #[test]
    fn test_partial_eq_different_lengths() {
        let config1 = EventCounterConfig::new(vec![
            IntervalConfig::new_unchecked(60, TimeUnit::Minutes),
            IntervalConfig::new_unchecked(72, TimeUnit::Hours),
        ]);
        let config2 =
            EventCounterConfig::new(vec![IntervalConfig::new_unchecked(60, TimeUnit::Minutes)]);
        assert_ne!(config1, config2);
    }

    #[test]
    fn test_partial_eq_empty_configs() {
        let config1 = EventCounterConfig::empty();
        let config2 = EventCounterConfig::empty();
        assert_eq!(config1, config2);
    }

    #[test]
    fn test_partial_eq_default_configs() {
        let config1 = EventCounterConfig::default();
        let config2 = EventCounterConfig::default();
        assert_eq!(config1, config2);
    }
}