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> {
28 let value = value.into();
29 if value.trim().is_empty() {
30 return Err(TaskCoreError::invalid_value(
31 "task dedupe key must not be empty",
32 ));
33 }
34 if value.len() > TASK_DEDUPE_KEY_MAX_LEN {
35 return Err(TaskCoreError::invalid_value(format!(
36 "task dedupe key must be at most {TASK_DEDUPE_KEY_MAX_LEN} bytes"
37 )));
38 }
39 Ok(Self(value))
40 }
41
42 #[must_use]
44 pub fn as_str(&self) -> &str {
45 &self.0
46 }
47
48 #[must_use]
50 pub fn into_string(self) -> String {
51 self.0
52 }
53}
54
55pub fn scheduled_task_dedupe_key(
61 namespace: &str,
62 task_name: &str,
63 scheduled_at: DateTime<Utc>,
64) -> Result<TaskDedupeKey> {
65 TaskDedupeKey::new(format!(
66 "schedule:{namespace}:{task_name}:{}",
67 scheduled_at.to_rfc3339_opts(SecondsFormat::Secs, true)
68 ))
69}
70
71#[cfg(test)]
72mod tests {
73 use chrono::{TimeZone, Utc};
74
75 use super::{TASK_DEDUPE_KEY_MAX_LEN, TaskDedupeKey, scheduled_task_dedupe_key};
76
77 #[test]
78 fn task_dedupe_key_rejects_empty_values() {
79 assert!(TaskDedupeKey::new(" ").is_err());
80 }
81
82 #[test]
83 fn task_dedupe_key_rejects_values_over_storage_limit() {
84 assert!(TaskDedupeKey::new("x".repeat(TASK_DEDUPE_KEY_MAX_LEN + 1)).is_err());
85 }
86
87 #[test]
88 fn scheduled_task_dedupe_key_is_stable_and_compact() {
89 let key = scheduled_task_dedupe_key(
90 "aster_yggdrasil",
91 "task-cleanup",
92 Utc.with_ymd_and_hms(2026, 6, 26, 1, 2, 3).unwrap(),
93 )
94 .unwrap();
95
96 assert_eq!(
97 key.as_str(),
98 "schedule:aster_yggdrasil:task-cleanup:2026-06-26T01:02:03Z"
99 );
100 }
101}