ppoppo-infra 0.1.0

Backend-agnostic infrastructure traits for caching, queuing, and messaging
Documentation
//! Shared types used across infrastructure trait boundaries.

use serde::{Deserialize, Serialize};
use time::OffsetDateTime;

/// Result of a rate limit check.
#[derive(Debug, Clone)]
pub struct RateLimitResult {
    /// Whether the request is allowed.
    pub allowed: bool,
    /// Current weighted request count.
    pub current_count: f64,
    /// Maximum allowed requests in the window.
    pub limit: i32,
    /// Remaining requests before limit is reached.
    pub remaining: i32,
    /// When the current window resets.
    pub reset_at: OffsetDateTime,
}

impl RateLimitResult {
    /// Returns an error if rate limit exceeded, otherwise Ok(self).
    pub fn into_result(self) -> crate::Result<Self> {
        if self.allowed {
            Ok(self)
        } else {
            Err(crate::Error::RateLimitExceeded {
                current: self.current_count,
                limit: self.limit,
                reset_at: self.reset_at,
            })
        }
    }

    /// Returns the number of seconds until reset.
    pub fn retry_after(&self) -> i64 {
        let now = OffsetDateTime::now_utc();
        (self.reset_at - now).whole_seconds().max(0)
    }

    /// Create an "allowed" result for fail-open fallback.
    ///
    /// Used by `ResilientRateLimit` when the backing store is unavailable.
    /// L1 (in-memory governor) still provides per-pod protection.
    pub fn allowed_fallback(max_requests: i32, window_seconds: i32) -> Self {
        Self {
            allowed: true,
            current_count: 0.0,
            limit: max_requests,
            remaining: max_requests,
            reset_at: OffsetDateTime::now_utc()
                + time::Duration::seconds(i64::from(window_seconds)),
        }
    }
}

/// Job priority levels.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Priority {
    /// Lowest priority (-100)
    Low = -100,
    /// Default priority (0)
    #[default]
    Normal = 0,
    /// Higher priority (100)
    High = 100,
    /// Highest priority (500)
    Critical = 500,
}

impl Priority {
    /// Convert to integer value for storage.
    pub fn as_i32(self) -> i32 {
        self as i32
    }

    /// Create from integer value.
    pub fn from_i32(value: i32) -> Self {
        match value {
            v if v <= -100 => Priority::Low,
            v if v <= 0 => Priority::Normal,
            v if v <= 100 => Priority::High,
            _ => Priority::Critical,
        }
    }
}

/// Job status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum JobStatus {
    /// Job is waiting to be processed.
    Pending,
    /// Job is currently being processed by a worker.
    Processing,
    /// Job completed successfully.
    Completed,
    /// Job failed after all retry attempts.
    Failed,
}

impl JobStatus {
    /// Parse from string representation.
    pub fn parse(s: &str) -> Self {
        match s {
            "pending" => Self::Pending,
            "processing" => Self::Processing,
            "completed" => Self::Completed,
            "failed" => Self::Failed,
            _ => Self::Failed,
        }
    }

    /// Convert to string representation.
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Pending => "pending",
            Self::Processing => "processing",
            Self::Completed => "completed",
            Self::Failed => "failed",
        }
    }
}

/// A job retrieved from the queue.
///
/// Uses `serde_json::Value` for the payload to maintain dyn-compatibility.
#[derive(Debug, Clone)]
pub struct Job {
    /// Unique job ID (ULID).
    pub id: String,
    /// Queue name this job belongs to.
    pub queue_name: String,
    /// Job payload data as JSON.
    pub payload: serde_json::Value,
    /// Current attempt number (1-indexed).
    pub attempts: i32,
    /// Maximum allowed attempts.
    pub max_attempts: i32,
    /// When the job was created.
    pub created_at: OffsetDateTime,
}

/// Queue statistics.
#[derive(Debug, Clone)]
pub struct QueueStats {
    /// Queue name.
    pub queue_name: String,
    /// Number of pending jobs.
    pub pending: i64,
    /// Number of processing jobs.
    pub processing: i64,
    /// Number of completed jobs.
    pub completed: i64,
    /// Number of failed jobs.
    pub failed: i64,
}

impl QueueStats {
    /// Total jobs in the queue (all statuses).
    pub fn total(&self) -> i64 {
        self.pending + self.processing + self.completed + self.failed
    }

    /// Active jobs (pending + processing).
    pub fn active(&self) -> i64 {
        self.pending + self.processing
    }
}

/// A typed job retrieved from the queue.
///
/// Used by [`JobQueueExt::pop_typed`](crate::JobQueueExt::pop_typed) for
/// deserialized payloads.
#[derive(Debug, Clone)]
pub struct TypedJob<T> {
    /// Unique job ID (ULID).
    pub id: String,
    /// Queue name this job belongs to.
    pub queue_name: String,
    /// Deserialized job payload.
    pub payload: T,
    /// Current attempt number (1-indexed).
    pub attempts: i32,
    /// Maximum allowed attempts.
    pub max_attempts: i32,
    /// When the job was created.
    pub created_at: OffsetDateTime,
}

/// A received pub/sub notification.
#[derive(Debug, Clone)]
pub struct Notification {
    /// Channel the notification was received on.
    pub channel: String,
    /// Raw payload string.
    pub payload: String,
}

impl Notification {
    /// Deserialize the payload as JSON.
    pub fn decode<T: serde::de::DeserializeOwned>(&self) -> crate::Result<T> {
        let value = serde_json::from_str(&self.payload)?;
        Ok(value)
    }
}

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

    #[test]
    fn test_priority_conversion() {
        assert_eq!(Priority::Low.as_i32(), -100);
        assert_eq!(Priority::Normal.as_i32(), 0);
        assert_eq!(Priority::High.as_i32(), 100);
        assert_eq!(Priority::Critical.as_i32(), 500);

        assert_eq!(Priority::from_i32(-150), Priority::Low);
        assert_eq!(Priority::from_i32(-50), Priority::Normal);
        assert_eq!(Priority::from_i32(50), Priority::High);
        assert_eq!(Priority::from_i32(1000), Priority::Critical);
    }

    #[test]
    fn test_queue_stats() {
        let stats = QueueStats {
            queue_name: "test".to_string(),
            pending: 10,
            processing: 5,
            completed: 100,
            failed: 3,
        };

        assert_eq!(stats.total(), 118);
        assert_eq!(stats.active(), 15);
    }

    #[test]
    fn test_job_status_parse() {
        assert_eq!(JobStatus::parse("pending"), JobStatus::Pending);
        assert_eq!(JobStatus::parse("processing"), JobStatus::Processing);
        assert_eq!(JobStatus::parse("completed"), JobStatus::Completed);
        assert_eq!(JobStatus::parse("failed"), JobStatus::Failed);
        assert_eq!(JobStatus::parse("unknown"), JobStatus::Failed);
    }
}