1use std::fmt;
4
5use jiff::Timestamp;
6use secrecy::SecretString;
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9macro_rules! id_type {
10 ($(#[$meta:meta])* $name:ident) => {
11 $(#[$meta])*
12 #[derive(
13 Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
14 )]
15 #[serde(transparent)]
16 pub struct $name(pub i64);
17
18 impl $name {
19 pub const fn get(self) -> i64 {
21 self.0
22 }
23 }
24
25 impl fmt::Display for $name {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 fmt::Display::fmt(&self.0, f)
28 }
29 }
30
31 impl std::str::FromStr for $name {
32 type Err = std::num::ParseIntError;
33
34 fn from_str(s: &str) -> Result<Self, Self::Err> {
35 s.parse().map(Self)
36 }
37 }
38
39 impl From<i64> for $name {
40 fn from(value: i64) -> Self {
41 Self(value)
42 }
43 }
44
45 impl From<$name> for i64 {
46 fn from(value: $name) -> Self {
47 value.0
48 }
49 }
50 };
51}
52
53id_type!(
54 ScaleSetId
56);
57id_type!(
58 RunnerGroupId
60);
61id_type!(
62 RunnerId
64);
65id_type!(
66 MessageId
68);
69id_type!(
70 JobRequestId
73);
74
75impl RunnerGroupId {
76 pub const DEFAULT: Self = Self(1);
78}
79
80#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
85#[serde(transparent)]
86pub struct SessionId(String);
87
88impl SessionId {
89 #[cfg(feature = "testing")]
90 pub(crate) fn new(id: impl Into<String>) -> Self {
91 Self(id.into())
92 }
93
94 pub fn as_str(&self) -> &str {
96 &self.0
97 }
98}
99
100impl fmt::Display for SessionId {
101 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102 f.write_str(&self.0)
103 }
104}
105
106impl AsRef<str> for SessionId {
107 fn as_ref(&self) -> &str {
108 &self.0
109 }
110}
111
112pub const DEFAULT_RUNNER_GROUP: &str = "default";
114
115pub const LABEL_TYPE_SYSTEM: &str = "System";
117
118#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
120#[non_exhaustive]
121pub struct Label {
122 pub name: String,
124 #[serde(rename = "type", default)]
126 pub label_type: String,
127}
128
129impl Label {
130 pub fn new(name: impl Into<String>) -> Self {
132 Self::with_type(name, LABEL_TYPE_SYSTEM)
133 }
134
135 pub fn with_type(name: impl Into<String>, label_type: impl Into<String>) -> Self {
137 Self {
138 name: name.into(),
139 label_type: label_type.into(),
140 }
141 }
142}
143
144impl From<&str> for Label {
145 fn from(name: &str) -> Self {
146 Self::new(name)
147 }
148}
149
150impl From<String> for Label {
151 fn from(name: String) -> Self {
152 Self::new(name)
153 }
154}
155
156#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "camelCase", default)]
159#[non_exhaustive]
160pub struct RunnerGroup {
161 pub id: RunnerGroupId,
163 pub name: String,
165 pub size: i64,
167 #[serde(rename = "isDefaultGroup")]
169 pub is_default: bool,
170}
171
172#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase", default)]
175#[non_exhaustive]
176pub struct RunnerSetting {
177 pub disable_update: bool,
179}
180
181#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase", default)]
189#[non_exhaustive]
190pub struct ScaleSetStatistics {
191 #[serde(deserialize_with = "count")]
193 pub total_available_jobs: u32,
194 #[serde(deserialize_with = "count")]
196 pub total_acquired_jobs: u32,
197 #[serde(deserialize_with = "count")]
200 pub total_assigned_jobs: u32,
201 #[serde(deserialize_with = "count")]
203 pub total_running_jobs: u32,
204 #[serde(deserialize_with = "count")]
206 pub total_registered_runners: u32,
207 #[serde(deserialize_with = "count")]
209 pub total_busy_runners: u32,
210 #[serde(deserialize_with = "count")]
212 pub total_idle_runners: u32,
213}
214
215#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(rename_all = "camelCase", default)]
218#[non_exhaustive]
219pub struct RunnerScaleSet {
220 pub id: ScaleSetId,
222 pub name: String,
224 pub runner_group_id: RunnerGroupId,
226 pub runner_group_name: String,
228 pub labels: Vec<Label>,
230 #[serde(rename = "RunnerSetting")]
232 pub runner_setting: RunnerSetting,
233 #[serde(deserialize_with = "optional_time")]
235 pub created_on: Option<Timestamp>,
236 pub runner_jit_config_url: Option<String>,
238 pub statistics: Option<ScaleSetStatistics>,
240}
241
242#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
254#[serde(rename_all = "camelCase")]
255#[non_exhaustive]
256pub struct RunnerScaleSetSpec {
257 #[serde(skip_serializing_if = "Option::is_none")]
259 pub name: Option<String>,
260 #[serde(skip_serializing_if = "Option::is_none")]
262 pub runner_group_id: Option<RunnerGroupId>,
263 #[serde(skip_serializing_if = "Option::is_none")]
265 pub runner_group_name: Option<String>,
266 #[serde(skip_serializing_if = "Vec::is_empty")]
268 pub labels: Vec<Label>,
269 #[serde(rename = "RunnerSetting", skip_serializing_if = "Option::is_none")]
271 pub runner_setting: Option<RunnerSetting>,
272}
273
274impl RunnerScaleSetSpec {
275 pub fn new(name: impl Into<String>) -> Self {
277 Self {
278 name: Some(name.into()),
279 ..Self::default()
280 }
281 }
282
283 #[must_use]
285 pub fn name(mut self, name: impl Into<String>) -> Self {
286 self.name = Some(name.into());
287 self
288 }
289
290 #[must_use]
292 pub fn runner_group_id(mut self, id: RunnerGroupId) -> Self {
293 self.runner_group_id = Some(id);
294 self
295 }
296
297 #[must_use]
299 pub fn runner_group_name(mut self, name: impl Into<String>) -> Self {
300 self.runner_group_name = Some(name.into());
301 self
302 }
303
304 #[must_use]
306 pub fn label(mut self, label: impl Into<Label>) -> Self {
307 self.labels.push(label.into());
308 self
309 }
310
311 #[must_use]
316 pub fn labels<I, L>(mut self, labels: I) -> Self
317 where
318 I: IntoIterator<Item = L>,
319 L: Into<Label>,
320 {
321 self.labels.extend(labels.into_iter().map(Into::into));
322 self
323 }
324
325 #[must_use]
327 pub fn disable_update(mut self) -> Self {
328 self.runner_setting = Some(RunnerSetting {
329 disable_update: true,
330 });
331 self
332 }
333
334 #[must_use]
337 pub fn enable_update(mut self) -> Self {
338 self.runner_setting = Some(RunnerSetting {
339 disable_update: false,
340 });
341 self
342 }
343
344 pub(crate) fn for_create(&self) -> Result<Self, String> {
347 let name = match self.name.as_deref() {
348 Some(name) if !name.trim().is_empty() => name,
349 _ => return Err("runner scale set name is required".into()),
350 };
351 let mut spec = self.for_update();
352 if spec.labels.is_empty() {
353 spec.labels.push(Label::new(name));
354 }
355 spec.runner_setting.get_or_insert_default();
356 Ok(spec)
357 }
358
359 pub(crate) fn for_update(&self) -> Self {
361 let mut spec = self.clone();
362 for label in &mut spec.labels {
363 if label.label_type.is_empty() {
364 LABEL_TYPE_SYSTEM.clone_into(&mut label.label_type);
365 }
366 }
367 spec
368 }
369}
370
371#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
373#[serde(rename_all = "camelCase")]
374#[non_exhaustive]
375pub struct JitRunnerSetting {
376 pub name: String,
378 pub work_folder: String,
380}
381
382impl JitRunnerSetting {
383 pub fn new(name: impl Into<String>) -> Self {
385 Self {
386 name: name.into(),
387 work_folder: String::new(),
388 }
389 }
390
391 pub fn unique(prefix: &str) -> Self {
394 Self::new(format!("{prefix}-{:012x}", fastrand::u64(..) >> 16))
395 }
396
397 #[must_use]
399 pub fn work_folder(mut self, folder: impl Into<String>) -> Self {
400 self.work_folder = folder.into();
401 self
402 }
403}
404
405#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
407#[serde(rename_all = "camelCase", default)]
408#[non_exhaustive]
409pub struct RunnerReference {
410 pub id: RunnerId,
412 pub name: String,
414 pub runner_scale_set_id: ScaleSetId,
416}
417
418#[derive(Debug, Clone, Default, Deserialize)]
424#[serde(default)]
425#[non_exhaustive]
426pub struct JitRunnerConfig {
427 pub runner: Option<RunnerReference>,
429 #[serde(rename = "encodedJITConfig")]
433 pub encoded_jit_config: SecretString,
434}
435
436#[derive(Debug, Clone, Default, Deserialize)]
438#[serde(rename_all = "camelCase", default)]
439#[non_exhaustive]
440pub struct SessionInfo {
441 pub session_id: SessionId,
443 pub owner_name: String,
445 pub runner_scale_set: Option<RunnerScaleSet>,
447 pub message_queue_url: String,
449 pub message_queue_access_token: SecretString,
451 pub statistics: Option<ScaleSetStatistics>,
453}
454
455#[derive(Debug, Clone, Default, PartialEq, Eq)]
457#[non_exhaustive]
458pub struct ScaleSetMessage {
459 pub id: MessageId,
463 pub statistics: Option<ScaleSetStatistics>,
465 pub jobs: Vec<JobMessage>,
467}
468
469impl ScaleSetMessage {
470 pub fn jobs_available(&self) -> impl Iterator<Item = &JobAvailable> {
473 self.jobs.iter().filter_map(|m| match m {
474 JobMessage::Available(j) => Some(j),
475 _ => None,
476 })
477 }
478
479 pub fn jobs_assigned(&self) -> impl Iterator<Item = &JobAssigned> {
481 self.jobs.iter().filter_map(|m| match m {
482 JobMessage::Assigned(j) => Some(j),
483 _ => None,
484 })
485 }
486
487 pub fn jobs_started(&self) -> impl Iterator<Item = &JobStarted> {
489 self.jobs.iter().filter_map(|m| match m {
490 JobMessage::Started(j) => Some(j),
491 _ => None,
492 })
493 }
494
495 pub fn jobs_completed(&self) -> impl Iterator<Item = &JobCompleted> {
497 self.jobs.iter().filter_map(|m| match m {
498 JobMessage::Completed(j) => Some(j),
499 _ => None,
500 })
501 }
502}
503
504#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
506#[serde(tag = "messageType")]
507#[non_exhaustive]
508pub enum JobMessage {
509 #[serde(rename = "JobAvailable")]
511 Available(JobAvailable),
512 #[serde(rename = "JobAssigned")]
514 Assigned(JobAssigned),
515 #[serde(rename = "JobStarted")]
517 Started(JobStarted),
518 #[serde(rename = "JobCompleted")]
520 Completed(JobCompleted),
521 #[serde(other)]
523 Unknown,
524}
525
526impl JobMessage {
527 pub fn job(&self) -> Option<&JobInfo> {
529 match self {
530 Self::Available(m) => Some(&m.job),
531 Self::Assigned(m) => Some(&m.job),
532 Self::Started(m) => Some(&m.job),
533 Self::Completed(m) => Some(&m.job),
534 Self::Unknown => None,
535 }
536 }
537}
538
539#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
541#[serde(rename_all = "camelCase", default)]
542#[non_exhaustive]
543pub struct JobInfo {
544 pub runner_request_id: JobRequestId,
546 pub repository_name: String,
548 pub owner_name: String,
550 pub job_id: String,
552 pub job_workflow_ref: String,
554 pub job_display_name: String,
556 pub workflow_run_id: i64,
558 pub event_name: String,
560 pub request_labels: Vec<String>,
562 #[serde(deserialize_with = "optional_time")]
564 pub queue_time: Option<Timestamp>,
565 #[serde(deserialize_with = "optional_time")]
567 pub scale_set_assign_time: Option<Timestamp>,
568 #[serde(deserialize_with = "optional_time")]
570 pub runner_assign_time: Option<Timestamp>,
571 #[serde(deserialize_with = "optional_time")]
573 pub finish_time: Option<Timestamp>,
574}
575
576#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
578#[serde(rename_all = "camelCase")]
579#[non_exhaustive]
580pub struct JobAvailable {
581 #[serde(default)]
583 pub acquire_job_url: String,
584 #[serde(flatten)]
586 pub job: JobInfo,
587}
588
589#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
591#[non_exhaustive]
592pub struct JobAssigned {
593 #[serde(flatten)]
595 pub job: JobInfo,
596}
597
598#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
600#[serde(rename_all = "camelCase")]
601#[non_exhaustive]
602pub struct JobStarted {
603 #[serde(default)]
605 pub runner_id: RunnerId,
606 #[serde(default)]
608 pub runner_name: String,
609 #[serde(flatten)]
611 pub job: JobInfo,
612}
613
614#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
619#[serde(rename_all = "camelCase")]
620#[non_exhaustive]
621pub struct JobCompleted {
622 #[serde(default)]
624 pub result: String,
625 #[serde(
627 default,
628 deserialize_with = "optional_id",
629 serialize_with = "id_or_zero"
630 )]
631 pub runner_id: Option<RunnerId>,
632 #[serde(
634 default,
635 deserialize_with = "optional_name",
636 serialize_with = "name_or_empty"
637 )]
638 pub runner_name: Option<String>,
639 #[serde(flatten)]
641 pub job: JobInfo,
642}
643
644#[derive(Debug, Deserialize)]
646#[serde(rename_all = "camelCase")]
647pub(crate) struct RawMessage {
648 #[serde(default, deserialize_with = "null_as_default")]
649 pub message_id: MessageId,
650 #[serde(default, deserialize_with = "null_as_default")]
651 pub message_type: String,
652 #[serde(default, deserialize_with = "null_as_default")]
653 pub body: String,
654 #[serde(default)]
655 pub statistics: Option<ScaleSetStatistics>,
656}
657
658pub(crate) const MESSAGE_TYPE_JOB_MESSAGES: &str = "RunnerScaleSetJobMessages";
659
660impl ScaleSetMessage {
661 pub(crate) fn from_raw(raw: &RawMessage) -> Self {
665 let id = raw.message_id;
666 let entries: Vec<serde_json::Value> = if raw.message_type != MESSAGE_TYPE_JOB_MESSAGES {
667 tracing::warn!(message_id = %id, message_type = %raw.message_type, "skipping a message of an unknown type");
668 Vec::new()
669 } else if raw.body.is_empty() {
670 Vec::new()
671 } else {
672 serde_json::from_str(&raw.body).unwrap_or_else(|e| {
673 tracing::warn!(error = %e, message_id = %id, "skipping a message body that could not be decoded");
674 Vec::new()
675 })
676 };
677 let jobs = entries
679 .into_iter()
680 .map(|entry| {
681 JobMessage::deserialize(&entry).unwrap_or_else(|e| {
682 tracing::warn!(error = %e, message_id = %id, "skipping a job message that could not be decoded");
683 JobMessage::Unknown
684 })
685 })
686 .collect();
687 Self {
688 id,
689 statistics: raw.statistics,
690 jobs,
691 }
692 }
693}
694
695#[derive(Debug, Deserialize)]
697pub(crate) struct ListResponse<T> {
698 #[serde(default)]
699 pub count: usize,
700 #[serde(default = "Vec::new")]
701 pub value: Vec<T>,
702}
703
704fn optional_time<'de, D>(deserializer: D) -> Result<Option<Timestamp>, D::Error>
706where
707 D: Deserializer<'de>,
708{
709 let value = Option::<Timestamp>::deserialize(deserializer)?;
710 Ok(value.filter(|t| t.as_second() > GO_ZERO_TIME_SECS))
711}
712
713fn count<'de, D>(deserializer: D) -> Result<u32, D::Error>
716where
717 D: Deserializer<'de>,
718{
719 let value = Option::<i64>::deserialize(deserializer)?.unwrap_or(0);
720 Ok(u32::try_from(value.max(0)).unwrap_or(u32::MAX))
721}
722
723fn optional_id<'de, D>(deserializer: D) -> Result<Option<RunnerId>, D::Error>
725where
726 D: Deserializer<'de>,
727{
728 let value = Option::<RunnerId>::deserialize(deserializer)?;
729 Ok(value.filter(|id| id.0 != 0))
730}
731
732fn optional_name<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
734where
735 D: Deserializer<'de>,
736{
737 let value = Option::<String>::deserialize(deserializer)?;
738 Ok(value.filter(|name| !name.is_empty()))
739}
740
741#[expect(clippy::ref_option, reason = "serde passes fields by reference")]
743fn id_or_zero<S: Serializer>(id: &Option<RunnerId>, serializer: S) -> Result<S::Ok, S::Error> {
744 id.unwrap_or_default().serialize(serializer)
745}
746
747#[expect(clippy::ref_option, reason = "serde passes fields by reference")]
749fn name_or_empty<S: Serializer>(name: &Option<String>, serializer: S) -> Result<S::Ok, S::Error> {
750 name.as_deref().unwrap_or_default().serialize(serializer)
751}
752
753fn null_as_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
755where
756 D: Deserializer<'de>,
757 T: Deserialize<'de> + Default,
758{
759 Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
760}
761
762const GO_ZERO_TIME_SECS: i64 = -62_135_596_800;
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768
769 #[test]
770 fn decodes_job_messages_in_order() {
771 let body = serde_json::json!([
772 {"messageType": "JobAvailable", "acquireJobUrl": "u", "runnerRequestId": 1,
773 "queueTime": "2024-01-01T00:00:00Z", "finishTime": "0001-01-01T00:00:00Z"},
774 {"messageType": "JobStarted", "runnerRequestId": 2, "runnerId": 7, "runnerName": "r"},
775 {"messageType": "SomethingNew", "x": 1},
776 {"messageType": "JobCompleted", "runnerRequestId": 3, "result": "canceled",
777 "runnerId": 0, "runnerName": ""},
778 {"messageType": "JobAssigned", "runnerRequestId": 4, "requestLabels": ["a"]},
779 ]);
780 let raw = RawMessage {
781 message_id: MessageId(5),
782 message_type: MESSAGE_TYPE_JOB_MESSAGES.into(),
783 body: body.to_string(),
784 statistics: Some(ScaleSetStatistics {
785 total_assigned_jobs: 3,
786 ..Default::default()
787 }),
788 };
789 let msg = ScaleSetMessage::from_raw(&raw);
790 assert_eq!(msg.id, MessageId(5));
791 assert_eq!(msg.jobs.len(), 5);
792 assert!(matches!(msg.jobs[2], JobMessage::Unknown));
793
794 let available: Vec<_> = msg.jobs_available().collect();
795 assert_eq!(available[0].acquire_job_url, "u");
796 assert_eq!(available[0].job.runner_request_id, JobRequestId(1));
797 assert!(available[0].job.queue_time.is_some());
798 assert_eq!(available[0].job.finish_time, None);
799
800 let started: Vec<_> = msg.jobs_started().collect();
801 assert_eq!(started[0].runner_id, RunnerId(7));
802 let completed = msg.jobs_completed().next().unwrap();
803 assert_eq!(completed.result, "canceled");
804 assert_eq!(
805 (completed.runner_id, completed.runner_name.as_deref()),
806 (None, None)
807 );
808 assert_eq!(
809 msg.jobs_assigned().next().unwrap().job.request_labels,
810 vec!["a"]
811 );
812 }
813
814 #[test]
815 fn tolerates_a_malformed_job_message() {
816 let body = serde_json::json!([
817 {"messageType": "JobStarted", "runnerId": "not a number"},
818 {"messageType": "JobAssigned", "runnerRequestId": 4},
819 ]);
820 let raw = RawMessage {
821 message_id: MessageId(1),
822 message_type: MESSAGE_TYPE_JOB_MESSAGES.into(),
823 body: body.to_string(),
824 statistics: None,
825 };
826 let msg = ScaleSetMessage::from_raw(&raw);
827 assert!(matches!(msg.jobs[0], JobMessage::Unknown));
828 assert_eq!(msg.jobs_assigned().count(), 1);
829 }
830
831 #[test]
832 fn skips_what_it_cannot_read() {
833 for (message_type, body) in [("Other", "[]"), (MESSAGE_TYPE_JOB_MESSAGES, "not json")] {
834 let raw = RawMessage {
835 message_id: MessageId(9),
836 message_type: message_type.into(),
837 body: body.into(),
838 statistics: Some(ScaleSetStatistics::default()),
839 };
840 let msg = ScaleSetMessage::from_raw(&raw);
841 assert_eq!(msg.id, MessageId(9));
842 assert!(msg.jobs.is_empty());
843 assert!(msg.statistics.is_some());
844 }
845 }
846
847 #[test]
848 fn spec_defaults_label_to_name() {
849 let spec = RunnerScaleSetSpec::new("set").for_create().unwrap();
850 assert_eq!(spec.labels, vec![Label::new("set")]);
851
852 let spec = RunnerScaleSetSpec::default()
853 .label(Label {
854 name: "x".into(),
855 label_type: String::new(),
856 })
857 .for_update();
858 assert_eq!(spec.labels[0].label_type, LABEL_TYPE_SYSTEM);
859
860 assert!(RunnerScaleSetSpec::default().for_create().is_err());
861 assert!(
863 RunnerScaleSetSpec::default()
864 .label(Label::new("x"))
865 .for_create()
866 .is_err()
867 );
868 }
869
870 #[test]
871 fn spec_serialization() {
872 let spec = RunnerScaleSetSpec::new("set")
873 .runner_group_id(RunnerGroupId(3))
874 .for_create()
875 .unwrap();
876 assert_eq!(
877 serde_json::to_value(&spec).unwrap(),
878 serde_json::json!({
879 "name": "set",
880 "runnerGroupId": 3,
881 "labels": [{"name": "set", "type": "System"}],
882 "RunnerSetting": {"disableUpdate": false}
883 })
884 );
885 }
886
887 #[test]
888 fn update_sends_only_what_was_set() {
889 let unchanged = RunnerScaleSetSpec::default().for_update();
890 assert_eq!(
891 serde_json::to_value(&unchanged).unwrap(),
892 serde_json::json!({})
893 );
894
895 let enable = RunnerScaleSetSpec::default().enable_update().for_update();
897 assert_eq!(
898 serde_json::to_value(&enable).unwrap(),
899 serde_json::json!({"RunnerSetting": {"disableUpdate": false}})
900 );
901 }
902
903 #[test]
904 fn secrets_are_redacted() {
905 let jit: JitRunnerConfig =
906 serde_json::from_str(r#"{"encodedJITConfig": "secret"}"#).unwrap();
907 assert!(!format!("{jit:?}").contains("secret"));
908
909 let session: SessionInfo =
910 serde_json::from_str(r#"{"messageQueueAccessToken": "secret"}"#).unwrap();
911 assert!(!format!("{session:?}").contains("secret"));
912 }
913
914 #[test]
915 fn session_ids_are_opaque() {
916 let session: SessionInfo = serde_json::from_str(r#"{"sessionId": "not-a-uuid"}"#).unwrap();
918 assert_eq!(session.session_id.as_str(), "not-a-uuid");
919 assert_eq!(session.session_id.to_string(), "not-a-uuid");
920 }
921
922 #[test]
923 fn counts_tolerate_out_of_range_values() {
924 let stats: ScaleSetStatistics =
925 serde_json::from_str(r#"{"totalAssignedJobs": -1, "totalRunningJobs": 99999999999}"#)
926 .unwrap();
927 assert_eq!(stats.total_assigned_jobs, 0);
928 assert_eq!(stats.total_running_jobs, u32::MAX);
929 }
930 #[test]
931 fn job_without_runner_uses_go_zero_values() {
932 let job = JobCompleted {
933 result: "canceled".into(),
934 ..JobCompleted::default()
935 };
936 let wire = serde_json::to_value(&job).unwrap();
937 assert_eq!(wire["runnerId"], 0, "{wire}");
938 assert_eq!(wire["runnerName"], "", "{wire}");
939 assert_eq!(serde_json::from_value::<JobCompleted>(wire).unwrap(), job);
940 }
941
942 #[test]
943 fn ids_parse_from_text() {
944 assert_eq!("42".parse::<RunnerId>(), Ok(RunnerId(42)));
945 assert!("x".parse::<ScaleSetId>().is_err());
946 }
947
948 #[test]
949 fn nulls_read_as_empty() {
950 let stats: ScaleSetStatistics =
951 serde_json::from_str(r#"{"totalAssignedJobs": null, "totalRunningJobs": 2}"#).unwrap();
952 assert_eq!(stats.total_assigned_jobs, 0);
953 assert_eq!(stats.total_running_jobs, 2);
954
955 let raw: RawMessage = serde_json::from_str(
956 r#"{"messageId": 3, "messageType": null, "body": null, "statistics": {"totalAssignedJobs": null}}"#,
957 )
958 .unwrap();
959 let msg = ScaleSetMessage::from_raw(&raw);
960 assert_eq!(msg.id, MessageId(3));
961 assert!(msg.jobs.is_empty());
962 assert_eq!(msg.statistics, Some(ScaleSetStatistics::default()));
963 }
964}