aster_forge_tasks/
dedupe.rs1use chrono::{DateTime, SecondsFormat, Utc};
11
12use crate::{Result, TaskCoreError};
13
14pub const TASK_DEDUPE_KEY_MAX_LEN: usize = 191;
16
17#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub struct TaskDedupeKey(String);
20
21impl TaskDedupeKey {
22 pub fn new(value: impl Into<String>) -> Result<Self> {
24 let value = value.into();
25 if value.trim().is_empty() {
26 return Err(TaskCoreError::invalid_value(
27 "task dedupe key must not be empty",
28 ));
29 }
30 if value.len() > TASK_DEDUPE_KEY_MAX_LEN {
31 return Err(TaskCoreError::invalid_value(format!(
32 "task dedupe key must be at most {TASK_DEDUPE_KEY_MAX_LEN} bytes"
33 )));
34 }
35 Ok(Self(value))
36 }
37
38 pub fn as_str(&self) -> &str {
40 &self.0
41 }
42
43 pub fn into_string(self) -> String {
45 self.0
46 }
47}
48
49pub fn scheduled_task_dedupe_key(
51 namespace: &str,
52 task_name: &str,
53 scheduled_at: DateTime<Utc>,
54) -> Result<TaskDedupeKey> {
55 TaskDedupeKey::new(format!(
56 "schedule:{namespace}:{task_name}:{}",
57 scheduled_at.to_rfc3339_opts(SecondsFormat::Secs, true)
58 ))
59}
60
61#[cfg(test)]
62mod tests {
63 use chrono::{TimeZone, Utc};
64
65 use super::{TASK_DEDUPE_KEY_MAX_LEN, TaskDedupeKey, scheduled_task_dedupe_key};
66
67 #[test]
68 fn task_dedupe_key_rejects_empty_values() {
69 assert!(TaskDedupeKey::new(" ").is_err());
70 }
71
72 #[test]
73 fn task_dedupe_key_rejects_values_over_storage_limit() {
74 assert!(TaskDedupeKey::new("x".repeat(TASK_DEDUPE_KEY_MAX_LEN + 1)).is_err());
75 }
76
77 #[test]
78 fn scheduled_task_dedupe_key_is_stable_and_compact() {
79 let key = scheduled_task_dedupe_key(
80 "aster_yggdrasil",
81 "task-cleanup",
82 Utc.with_ymd_and_hms(2026, 6, 26, 1, 2, 3).unwrap(),
83 )
84 .unwrap();
85
86 assert_eq!(
87 key.as_str(),
88 "schedule:aster_yggdrasil:task-cleanup:2026-06-26T01:02:03Z"
89 );
90 }
91}