use crate::jobs::JobId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum HistoryStatus {
Completed,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobHistoryRecord {
pub id: JobId,
pub job_type: String,
pub status: HistoryStatus,
pub enqueued_at: DateTime<Utc>,
pub started_at: DateTime<Utc>,
pub finished_at: DateTime<Utc>,
pub duration_ms: u64,
pub attempts: u32,
pub error_message: Option<String>,
}
impl JobHistoryRecord {
#[must_use]
pub fn completed(
id: JobId,
job_type: String,
enqueued_at: DateTime<Utc>,
started_at: DateTime<Utc>,
finished_at: DateTime<Utc>,
attempts: u32,
) -> Self {
let duration_ms = (finished_at - started_at)
.num_milliseconds()
.max(0)
.try_into()
.unwrap_or(0);
Self {
id,
job_type,
status: HistoryStatus::Completed,
enqueued_at,
started_at,
finished_at,
duration_ms,
attempts,
error_message: None,
}
}
#[must_use]
pub fn failed(
id: JobId,
job_type: String,
enqueued_at: DateTime<Utc>,
started_at: DateTime<Utc>,
finished_at: DateTime<Utc>,
attempts: u32,
error_message: String,
) -> Self {
let duration_ms = (finished_at - started_at)
.num_milliseconds()
.max(0)
.try_into()
.unwrap_or(0);
Self {
id,
job_type,
status: HistoryStatus::Failed,
enqueued_at,
started_at,
finished_at,
duration_ms,
attempts,
error_message: Some(error_message),
}
}
#[must_use]
pub fn matches_search(&self, query: &str) -> bool {
if query.is_empty() {
return true;
}
let query_lower = query.to_lowercase();
if self.job_type.to_lowercase().contains(&query_lower) {
return true;
}
if self.id.to_string().to_lowercase().contains(&query_lower) {
return true;
}
if let Some(error) = &self.error_message {
if error.to_lowercase().contains(&query_lower) {
return true;
}
}
false
}
}
#[derive(Debug)]
pub(super) struct JobHistory {
records: VecDeque<JobHistoryRecord>,
#[allow(dead_code)] max_records: usize,
}
impl JobHistory {
#[must_use]
pub(super) fn new(max_records: usize) -> Self {
Self {
records: VecDeque::with_capacity(max_records),
max_records,
}
}
#[allow(dead_code)] pub(super) fn add(&mut self, record: JobHistoryRecord) {
if self.records.len() >= self.max_records {
self.records.pop_front();
}
self.records.push_back(record);
}
#[must_use]
pub(super) fn get_page(
&self,
page: usize,
page_size: usize,
search_query: Option<&str>,
) -> (Vec<JobHistoryRecord>, usize) {
let filtered: Vec<_> = search_query.map_or_else(
|| self.records.iter().rev().cloned().collect(),
|query| {
self.records
.iter()
.rev() .filter(|record| record.matches_search(query))
.cloned()
.collect()
},
);
let total_count = filtered.len();
let page = page.max(1); let start_index = (page - 1) * page_size;
if start_index >= total_count {
return (Vec::new(), total_count);
}
let end_index = (start_index + page_size).min(total_count);
let page_records = filtered[start_index..end_index].to_vec();
(page_records, total_count)
}
#[must_use]
pub(super) fn len(&self) -> usize {
self.records.len()
}
#[must_use]
#[allow(dead_code)] pub(super) fn is_empty(&self) -> bool {
self.records.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
fn create_test_record(id_num: u128, job_type: &str, status: HistoryStatus) -> JobHistoryRecord {
let now = Utc::now();
let started = now - chrono::Duration::seconds(10);
let enqueued = started - chrono::Duration::seconds(5);
let id = JobId::from(Uuid::from_u128(id_num));
match status {
HistoryStatus::Completed => {
JobHistoryRecord::completed(id, job_type.to_string(), enqueued, started, now, 1)
}
HistoryStatus::Failed => JobHistoryRecord::failed(
id,
job_type.to_string(),
enqueued,
started,
now,
3,
"Test error".to_string(),
),
}
}
#[test]
fn test_history_bounded_capacity() {
let mut history = JobHistory::new(3);
for i in 1..=5 {
let record = create_test_record(i, "TestJob", HistoryStatus::Completed);
history.add(record);
}
assert_eq!(history.len(), 3);
let (records, _) = history.get_page(1, 10, None);
assert_eq!(records.len(), 3);
assert_eq!(*records[0].id.as_uuid(), Uuid::from_u128(5));
assert_eq!(*records[1].id.as_uuid(), Uuid::from_u128(4));
assert_eq!(*records[2].id.as_uuid(), Uuid::from_u128(3));
}
#[test]
fn test_history_pagination() {
let mut history = JobHistory::new(100);
for i in 1..=25 {
let record = create_test_record(i, "TestJob", HistoryStatus::Completed);
history.add(record);
}
let (page1, total) = history.get_page(1, 10, None);
assert_eq!(page1.len(), 10);
assert_eq!(total, 25);
assert_eq!(*page1[0].id.as_uuid(), Uuid::from_u128(25));
let (page2, total) = history.get_page(2, 10, None);
assert_eq!(page2.len(), 10);
assert_eq!(total, 25);
assert_eq!(*page2[0].id.as_uuid(), Uuid::from_u128(15));
let (page3, total) = history.get_page(3, 10, None);
assert_eq!(page3.len(), 5);
assert_eq!(total, 25);
assert_eq!(*page3[0].id.as_uuid(), Uuid::from_u128(5));
let (page4, total) = history.get_page(4, 10, None);
assert_eq!(page4.len(), 0);
assert_eq!(total, 25);
}
#[test]
fn test_history_search() {
let mut history = JobHistory::new(100);
history.add(create_test_record(1, "SendEmail", HistoryStatus::Completed));
history.add(create_test_record(2, "ProcessImage", HistoryStatus::Completed));
history.add(create_test_record(3, "SendEmail", HistoryStatus::Failed));
history.add(create_test_record(4, "GenerateReport", HistoryStatus::Completed));
let (results, total) = history.get_page(1, 10, Some("SendEmail"));
assert_eq!(results.len(), 2);
assert_eq!(total, 2);
assert_eq!(*results[0].id.as_uuid(), Uuid::from_u128(3)); assert_eq!(*results[1].id.as_uuid(), Uuid::from_u128(1));
let (results, total) = history.get_page(1, 10, Some("email"));
assert_eq!(results.len(), 2);
assert_eq!(total, 2);
let (results, total) = history.get_page(1, 10, Some("NonExistent"));
assert_eq!(results.len(), 0);
assert_eq!(total, 0);
}
#[test]
fn test_record_matches_search() {
let record = JobHistoryRecord::failed(
JobId::from(Uuid::from_u128(123)),
"SendEmail".to_string(),
Utc::now(),
Utc::now(),
Utc::now(),
2,
"SMTP connection failed".to_string(),
);
assert!(record.matches_search("SendEmail"));
assert!(record.matches_search("email"));
assert!(record.matches_search("SEND"));
assert!(record.matches_search("SMTP"));
assert!(record.matches_search("connection"));
assert!(record.matches_search("7b"));
assert!(!record.matches_search("xyz999"));
assert!(record.matches_search(""));
}
#[test]
fn test_record_duration_calculation() {
let enqueued = Utc::now();
let started = enqueued + chrono::Duration::seconds(5);
let finished = started + chrono::Duration::milliseconds(1500);
let record = JobHistoryRecord::completed(
JobId::from(Uuid::from_u128(1)),
"TestJob".to_string(),
enqueued,
started,
finished,
1,
);
assert_eq!(record.duration_ms, 1500);
}
}