1use std::fmt;
2use std::str::FromStr;
3use std::sync::Arc;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Clone, PartialEq, Eq, Hash)]
29pub struct TaskId {
30 packed: Arc<str>,
32 lang_end: u16,
34 module_end: u16,
36}
37
38impl TaskId {
39 fn from_parts(language: &str, module: &str, name: &str) -> Self {
44 let lang_end = language.len();
45 let module_end = lang_end + module.len();
46 assert!(
49 lang_end <= u16::MAX as usize,
50 "TaskId language field exceeds u16::MAX bytes ({lang_end} bytes)"
51 );
52 assert!(
53 module_end <= u16::MAX as usize,
54 "TaskId language + module exceeds u16::MAX bytes ({module_end} bytes)"
55 );
56 let mut buf = String::with_capacity(module_end + name.len());
57 buf.push_str(language);
58 buf.push_str(module);
59 buf.push_str(name);
60 Self {
61 packed: Arc::from(buf.as_str()),
62 lang_end: lang_end as u16,
63 module_end: module_end as u16,
64 }
65 }
66
67 pub fn new(module: impl Into<String>, name: impl Into<String>) -> Self {
75 let name = name.into();
76 assert!(
77 !name.contains('.'),
78 "TaskId name must not contain dots (breaks Display/FromStr round-trip): {name:?}"
79 );
80 let module = module.into();
81 Self::from_parts("", &module, &name)
82 }
83
84 pub fn try_new(
88 module: impl Into<String>,
89 name: impl Into<String>,
90 ) -> Result<Self, ParseTaskIdError> {
91 let name = name.into();
92 if name.contains('.') {
93 return Err(ParseTaskIdError(format!(
94 "TaskId name must not contain dots (breaks Display/FromStr round-trip): {name:?}"
95 )));
96 }
97 let module = module.into();
98 Ok(Self::from_parts("", &module, &name))
99 }
100
101 pub fn foreign(
108 language: impl Into<String>,
109 module: impl Into<String>,
110 name: impl Into<String>,
111 ) -> Self {
112 let language = language.into();
113 let name = name.into();
114 assert!(
115 !language.is_empty(),
116 "TaskId::foreign called with empty language; use TaskId::new for local tasks"
117 );
118 assert!(
119 !name.contains('.'),
120 "TaskId name must not contain dots (breaks Display/FromStr round-trip): {name:?}"
121 );
122 let module = module.into();
123 Self::from_parts(&language, &module, &name)
124 }
125
126 pub fn try_foreign(
130 language: impl Into<String>,
131 module: impl Into<String>,
132 name: impl Into<String>,
133 ) -> Result<Self, ParseTaskIdError> {
134 let language = language.into();
135 let name = name.into();
136 if language.is_empty() {
137 return Err(ParseTaskIdError(
138 "TaskId::try_foreign called with empty language; use try_new for local tasks"
139 .to_owned(),
140 ));
141 }
142 if name.contains('.') {
143 return Err(ParseTaskIdError(format!(
144 "TaskId name must not contain dots (breaks Display/FromStr round-trip): {name:?}"
145 )));
146 }
147 let module = module.into();
148 Ok(Self::from_parts(&language, &module, &name))
149 }
150
151 pub fn is_foreign(&self) -> bool {
153 self.lang_end > 0
154 }
155
156 pub fn language(&self) -> &str {
158 &self.packed[..self.lang_end as usize]
159 }
160
161 pub fn module(&self) -> &str {
163 &self.packed[self.lang_end as usize..self.module_end as usize]
164 }
165
166 pub fn name(&self) -> &str {
168 &self.packed[self.module_end as usize..]
169 }
170
171 pub fn config_key(&self) -> String {
175 format!("{}_{}", self.module().replace('.', "_"), self.name())
176 }
177}
178
179impl fmt::Debug for TaskId {
180 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181 f.debug_struct("TaskId")
182 .field("language", &self.language())
183 .field("module", &self.module())
184 .field("name", &self.name())
185 .finish()
186 }
187}
188
189impl Serialize for TaskId {
190 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
191 use serde::ser::SerializeStruct;
192 let mut s = serializer.serialize_struct("TaskId", 3)?;
193 s.serialize_field("language", self.language())?;
194 s.serialize_field("module", self.module())?;
195 s.serialize_field("name", self.name())?;
196 s.end()
197 }
198}
199
200impl<'de> Deserialize<'de> for TaskId {
201 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
202 #[derive(Deserialize)]
203 struct Fields {
204 #[serde(default)]
205 language: String,
206 module: String,
207 name: String,
208 }
209 let f = Fields::deserialize(deserializer)?;
210 Ok(TaskId::from_parts(&f.language, &f.module, &f.name))
211 }
212}
213
214impl fmt::Display for TaskId {
215 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216 if self.lang_end == 0 {
217 write!(f, "{}.{}", self.module(), self.name())
218 } else {
219 write!(f, "{}::{}.{}", self.language(), self.module(), self.name())
220 }
221 }
222}
223
224#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct ParseTaskIdError(String);
227
228impl fmt::Display for ParseTaskIdError {
229 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230 f.write_str(&self.0)
231 }
232}
233
234impl std::error::Error for ParseTaskIdError {}
235
236impl FromStr for TaskId {
237 type Err = ParseTaskIdError;
238
239 fn from_str(s: &str) -> Result<Self, Self::Err> {
248 if s.is_empty() {
249 return Err(ParseTaskIdError("empty task ID string".to_owned()));
250 }
251
252 let (language, rest) = if let Some(lang_end) = s.find("::") {
253 let lang = &s[..lang_end];
254 if lang.is_empty() {
255 return Err(ParseTaskIdError("empty language before '::'".to_owned()));
256 }
257 (lang, &s[lang_end + 2..])
258 } else {
259 ("", s)
260 };
261
262 let dot_pos = rest
263 .rfind('.')
264 .ok_or_else(|| ParseTaskIdError(format!("no '.' separator in task ID: {:?}", s)))?;
265
266 let module = &rest[..dot_pos];
267 let name = &rest[dot_pos + 1..];
268
269 if module.is_empty() {
270 return Err(ParseTaskIdError(format!(
271 "empty module in task ID: {:?}",
272 s
273 )));
274 }
275 if name.is_empty() {
276 return Err(ParseTaskIdError(format!("empty name in task ID: {:?}", s)));
277 }
278
279 if language.is_empty() {
280 Ok(TaskId::new(module, name))
281 } else {
282 Ok(TaskId::foreign(language, module, name))
283 }
284 }
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
292pub struct CallId {
293 pub task_id: TaskId,
294 pub args_id: Arc<str>,
295}
296
297impl CallId {
298 pub fn new(task_id: TaskId, args_id: impl Into<Arc<str>>) -> Self {
299 let args_id: Arc<str> = args_id.into();
300 debug_assert!(
301 !args_id.contains(':'),
302 "CallId args_id must not contain ':' (breaks Display/FromStr round-trip): {args_id:?}"
303 );
304 Self { task_id, args_id }
305 }
306}
307
308impl fmt::Display for CallId {
309 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
310 write!(f, "{}:{}", self.task_id, self.args_id)
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
316#[non_exhaustive]
317pub enum ParseCallIdError {
318 Format(String),
320 TaskId(ParseTaskIdError),
322}
323
324impl fmt::Display for ParseCallIdError {
325 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326 match self {
327 Self::Format(msg) => f.write_str(msg),
328 Self::TaskId(e) => write!(f, "invalid task_id in call ID: {e}"),
329 }
330 }
331}
332
333impl std::error::Error for ParseCallIdError {
334 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
335 match self {
336 Self::TaskId(e) => Some(e),
337 Self::Format(_) => None,
338 }
339 }
340}
341
342impl FromStr for CallId {
343 type Err = ParseCallIdError;
344
345 fn from_str(s: &str) -> Result<Self, Self::Err> {
347 let colon_pos = s.rfind(':').ok_or_else(|| {
349 ParseCallIdError::Format(format!("no ':' separator in call ID: {:?}", s))
350 })?;
351 let task_str = &s[..colon_pos];
352 let args_id = &s[colon_pos + 1..];
353 if args_id.is_empty() {
354 return Err(ParseCallIdError::Format(format!(
355 "empty args_id in call ID: {:?}",
356 s
357 )));
358 }
359 let task_id = task_str
360 .parse::<TaskId>()
361 .map_err(ParseCallIdError::TaskId)?;
362 Ok(CallId::new(task_id, args_id))
363 }
364}
365
366#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
371pub struct InvocationId(Arc<str>);
372
373impl InvocationId {
374 pub fn new() -> Self {
375 Self(Arc::from(uuid::Uuid::new_v4().to_string()))
376 }
377
378 pub fn from_string(id: impl Into<Arc<str>>) -> Self {
379 Self(id.into())
380 }
381
382 pub fn try_from_string(id: impl Into<Arc<str>>) -> Result<Self, String> {
384 let s: Arc<str> = id.into();
385 uuid::Uuid::parse_str(&s).map_err(|e| format!("invalid UUID for InvocationId: {e}"))?;
386 Ok(Self(s))
387 }
388
389 pub fn as_str(&self) -> &str {
391 &self.0
392 }
393}
394
395impl Default for InvocationId {
396 fn default() -> Self {
397 Self::new()
398 }
399}
400
401impl fmt::Display for InvocationId {
402 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
403 write!(f, "{}", self.0)
404 }
405}
406
407impl AsRef<str> for InvocationId {
408 fn as_ref(&self) -> &str {
409 &self.0
410 }
411}
412
413impl From<String> for InvocationId {
414 fn from(s: String) -> Self {
415 Self(Arc::from(s))
416 }
417}
418
419impl From<&str> for InvocationId {
420 fn from(s: &str) -> Self {
421 Self(Arc::from(s))
422 }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
427pub struct RunnerId(Arc<str>);
428
429impl RunnerId {
430 pub fn new() -> Self {
431 Self(Arc::from(uuid::Uuid::new_v4().to_string()))
432 }
433
434 pub fn from_string(id: impl Into<Arc<str>>) -> Self {
435 Self(id.into())
436 }
437
438 pub fn try_from_string(id: impl Into<Arc<str>>) -> Result<Self, String> {
440 let s: Arc<str> = id.into();
441 uuid::Uuid::parse_str(&s).map_err(|e| format!("invalid UUID for RunnerId: {e}"))?;
442 Ok(Self(s))
443 }
444
445 pub fn as_str(&self) -> &str {
447 &self.0
448 }
449}
450
451impl Default for RunnerId {
452 fn default() -> Self {
453 Self::new()
454 }
455}
456
457impl fmt::Display for RunnerId {
458 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
459 write!(f, "{}", self.0)
460 }
461}
462
463impl AsRef<str> for RunnerId {
464 fn as_ref(&self) -> &str {
465 &self.0
466 }
467}
468
469impl From<String> for RunnerId {
470 fn from(s: String) -> Self {
471 Self(Arc::from(s))
472 }
473}
474
475impl From<&str> for RunnerId {
476 fn from(s: &str) -> Self {
477 Self(Arc::from(s))
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 #[test]
486 fn task_id_display() {
487 let tid = TaskId::new("myapp.tasks", "process_order");
488 assert_eq!(tid.to_string(), "myapp.tasks.process_order");
489 }
490
491 #[test]
492 fn task_id_foreign_display() {
493 let tid = TaskId::foreign("python", "analytics.tasks", "train_model");
494 assert_eq!(tid.to_string(), "python::analytics.tasks.train_model");
495 }
496
497 #[test]
498 fn task_id_is_foreign() {
499 let local = TaskId::new("mod", "func");
500 assert!(!local.is_foreign());
501 assert_eq!(local.language(), "");
502
503 let foreign = TaskId::foreign("rust", "mod", "func");
504 assert!(foreign.is_foreign());
505 assert_eq!(foreign.language(), "rust");
506 }
507
508 #[test]
509 fn task_id_local_and_foreign_not_equal() {
510 let local = TaskId::new("mod", "func");
511 let foreign = TaskId::foreign("rust", "mod", "func");
512 assert_ne!(local, foreign);
513 }
514
515 #[test]
516 fn call_id_display() {
517 let cid = CallId::new(TaskId::new("myapp.tasks", "process_order"), "abc123");
518 assert_eq!(cid.to_string(), "myapp.tasks.process_order:abc123");
519 }
520
521 #[test]
522 fn invocation_id_uniqueness() {
523 let id1 = InvocationId::new();
524 let id2 = InvocationId::new();
525 assert_ne!(id1, id2);
526 }
527
528 #[test]
529 fn invocation_id_from_string() {
530 let id = InvocationId::from_string("my-custom-id");
531 assert_eq!(&*id.0, "my-custom-id");
532 assert_eq!(id.to_string(), "my-custom-id");
533 }
534
535 #[test]
536 fn invocation_id_default() {
537 let id = InvocationId::default();
538 assert!(!id.as_str().is_empty());
539 }
540
541 #[test]
542 fn runner_id_basics() {
543 let r1 = RunnerId::new();
544 let r2 = RunnerId::new();
545 assert_ne!(r1, r2);
546 assert!(!r1.to_string().is_empty());
547 }
548
549 #[test]
550 fn runner_id_from_string() {
551 let r = RunnerId::from_string("worker-1");
552 assert_eq!(&*r.0, "worker-1");
553 assert_eq!(r.to_string(), "worker-1");
554 }
555
556 #[test]
557 fn runner_id_default() {
558 let r = RunnerId::default();
559 assert!(!r.as_str().is_empty());
560 }
561
562 #[test]
563 fn serde_round_trip_task_id() {
564 let tid = TaskId::new("myapp", "process");
565 let json = serde_json::to_string(&tid).unwrap();
566 let back: TaskId = serde_json::from_str(&json).unwrap();
567 assert_eq!(tid, back);
568 }
569
570 #[test]
571 fn serde_round_trip_invocation_id() {
572 let id = InvocationId::from_string("abc-123");
573 let json = serde_json::to_string(&id).unwrap();
574 let back: InvocationId = serde_json::from_str(&json).unwrap();
575 assert_eq!(id, back);
576 }
577
578 #[test]
579 fn serde_round_trip_call_id() {
580 let cid = CallId::new(TaskId::new("m", "f"), "hash123");
581 let json = serde_json::to_string(&cid).unwrap();
582 let back: CallId = serde_json::from_str(&json).unwrap();
583 assert_eq!(cid, back);
584 }
585
586 #[test]
587 fn task_id_accessors() {
588 let tid = TaskId::new("myapp.tasks", "process_order");
589 assert_eq!(tid.module(), "myapp.tasks");
590 assert_eq!(tid.name(), "process_order");
591 }
592
593 #[test]
594 fn serde_backward_compat_missing_language() {
595 let json = r#"{"module":"myapp","name":"process"}"#;
597 let tid: TaskId = serde_json::from_str(json).unwrap();
598 assert_eq!(tid.language(), "");
599 assert_eq!(tid.module(), "myapp");
600 assert_eq!(tid.name(), "process");
601 assert!(!tid.is_foreign());
602 }
603
604 #[test]
605 fn serde_round_trip_foreign_task_id() {
606 let tid = TaskId::foreign("python", "analytics.tasks", "train_model");
607 let json = serde_json::to_string(&tid).unwrap();
608 let back: TaskId = serde_json::from_str(&json).unwrap();
609 assert_eq!(tid, back);
610 assert!(back.is_foreign());
611 assert_eq!(back.language(), "python");
612 }
613
614 #[test]
615 fn invocation_id_as_str() {
616 let id = InvocationId::from_string("test-id");
617 assert_eq!(id.as_str(), "test-id");
618 }
619
620 #[test]
621 fn invocation_id_try_from_string_valid() {
622 let uuid_str = uuid::Uuid::new_v4().to_string();
623 let result = InvocationId::try_from_string(uuid_str.clone());
624 assert!(result.is_ok());
625 assert_eq!(result.unwrap().as_str(), uuid_str);
626 }
627
628 #[test]
629 fn invocation_id_try_from_string_invalid() {
630 let result = InvocationId::try_from_string("not-a-uuid");
631 assert!(result.is_err());
632 assert!(result.unwrap_err().contains("invalid UUID"));
633 }
634
635 #[test]
636 fn runner_id_as_str() {
637 let r = RunnerId::from_string("worker-1");
638 assert_eq!(r.as_str(), "worker-1");
639 }
640
641 #[test]
644 fn parse_local_task_id() {
645 let tid: TaskId = "myapp.tasks.process_order".parse().unwrap();
646 assert_eq!(tid.module(), "myapp.tasks");
647 assert_eq!(tid.name(), "process_order");
648 assert!(!tid.is_foreign());
649 }
650
651 #[test]
652 fn parse_foreign_task_id() {
653 let tid: TaskId = "python::analytics.tasks.train_model".parse().unwrap();
654 assert_eq!(tid.language(), "python");
655 assert_eq!(tid.module(), "analytics.tasks");
656 assert_eq!(tid.name(), "train_model");
657 assert!(tid.is_foreign());
658 }
659
660 #[test]
661 fn parse_simple_task_id() {
662 let tid: TaskId = "mod.func".parse().unwrap();
663 assert_eq!(tid.module(), "mod");
664 assert_eq!(tid.name(), "func");
665 }
666
667 #[test]
668 fn parse_empty_string_fails() {
669 let result = "".parse::<TaskId>();
670 assert!(result.is_err());
671 assert!(result.unwrap_err().to_string().contains("empty"));
672 }
673
674 #[test]
675 fn parse_no_dot_fails() {
676 let result = "nodot".parse::<TaskId>();
677 assert!(result.is_err());
678 assert!(result.unwrap_err().to_string().contains("no '.'"));
679 }
680
681 #[test]
682 fn parse_trailing_dot_fails() {
683 let result = "module.".parse::<TaskId>();
684 assert!(result.is_err());
685 assert!(result.unwrap_err().to_string().contains("empty name"));
686 }
687
688 #[test]
689 fn parse_leading_dot_fails() {
690 let result = ".name".parse::<TaskId>();
691 assert!(result.is_err());
692 assert!(result.unwrap_err().to_string().contains("empty module"));
693 }
694
695 #[test]
696 fn parse_empty_language_fails() {
697 let result = "::mod.func".parse::<TaskId>();
698 assert!(result.is_err());
699 assert!(result.unwrap_err().to_string().contains("empty language"));
700 }
701
702 #[test]
703 fn parse_foreign_no_dot_fails() {
704 let result = "rust::nodot".parse::<TaskId>();
705 assert!(result.is_err());
706 assert!(result.unwrap_err().to_string().contains("no '.'"));
707 }
708
709 #[test]
710 fn display_parse_round_trip_local() {
711 let original = TaskId::new("myapp.tasks", "process_order");
712 let serialized = original.to_string();
713 let parsed: TaskId = serialized.parse().unwrap();
714 assert_eq!(original, parsed);
715 }
716
717 #[test]
718 fn display_parse_round_trip_foreign() {
719 let original = TaskId::foreign("python", "analytics.tasks", "train_model");
720 let serialized = original.to_string();
721 let parsed: TaskId = serialized.parse().unwrap();
722 assert_eq!(original, parsed);
723 }
724
725 #[test]
726 fn display_parse_round_trip_simple() {
727 let original = TaskId::new("mod", "func");
728 let serialized = original.to_string();
729 let parsed: TaskId = serialized.parse().unwrap();
730 assert_eq!(original, parsed);
731 }
732
733 #[test]
734 fn display_parse_round_trip_dotted_module() {
735 let original = TaskId::new("a.b.c", "func");
736 let serialized = original.to_string();
737 let parsed: TaskId = serialized.parse().unwrap();
738 assert_eq!(original, parsed);
739 }
740}