1use crate::error::ModelError;
8use regex::Regex;
9use serde::de::{self, Deserializer};
10use std::sync::LazyLock;
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub struct Identifier(pub String);
15
16static IDENTIFIER_RE: LazyLock<Regex> =
17 LazyLock::new(|| Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$").unwrap());
18
19impl Identifier {
20 pub fn new(s: &str) -> Result<Self, ModelError> {
21 if s.is_empty() || s.len() > 512 {
22 return Err(ModelError::DecodeValidation(format!(
23 "Identifier length must be 1..=512, got {}",
24 s.len()
25 )));
26 }
27 if !IDENTIFIER_RE.is_match(s) {
28 return Err(ModelError::DecodeValidation(format!(
29 "Identifier '{s}' does not match pattern [A-Za-z_][A-Za-z0-9_]*"
30 )));
31 }
32 Ok(Self(s.to_string()))
33 }
34
35 pub fn as_str(&self) -> &str {
36 &self.0
37 }
38}
39
40impl<'de> serde::Deserialize<'de> for Identifier {
41 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
42 let s = String::deserialize(deserializer)?;
43 Identifier::new(&s).map_err(de::Error::custom)
44 }
45}
46
47impl serde::Serialize for Identifier {
48 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
49 self.0.serialize(serializer)
50 }
51}
52
53impl std::fmt::Display for Identifier {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 write!(f, "{}", self.0)
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct Description(pub String);
62
63impl Description {
64 pub fn new(s: &str) -> Result<Self, ModelError> {
65 if s.chars().count() > 2048 {
66 return Err(ModelError::DecodeValidation(
67 "Description exceeds 2048 characters".into(),
68 ));
69 }
70 if s.chars()
71 .any(|c| c.is_control() && c != '\n' && c != '\r' && c != '\t')
72 {
73 return Err(ModelError::DecodeValidation(
74 "Description contains control characters".into(),
75 ));
76 }
77 Ok(Self(s.to_string()))
78 }
79}
80
81impl<'de> serde::Deserialize<'de> for Description {
82 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
83 let s = String::deserialize(deserializer)?;
84 Description::new(&s).map_err(serde::de::Error::custom)
85 }
86}
87
88impl serde::Serialize for Description {
89 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
90 self.0.serialize(serializer)
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Hash)]
96pub struct ExtensionName(pub String);
97
98static EXTENSION_NAME_RE: LazyLock<Regex> =
99 LazyLock::new(|| Regex::new(r"^[A-Z_0-9]{3,128}$").unwrap());
100
101impl ExtensionName {
102 pub fn new(s: &str) -> Result<Self, ModelError> {
103 if !EXTENSION_NAME_RE.is_match(s) {
104 return Err(ModelError::DecodeValidation(format!(
105 "Extension name '{s}' does not match pattern [A-Z_0-9]{{3,128}}"
106 )));
107 }
108 Ok(Self(s.to_string()))
109 }
110
111 pub fn as_str(&self) -> &str {
112 &self.0
113 }
114}
115
116impl<'de> serde::Deserialize<'de> for ExtensionName {
117 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
118 let s = String::deserialize(deserializer)?;
119 ExtensionName::new(&s).map_err(de::Error::custom)
120 }
121}
122
123impl serde::Serialize for ExtensionName {
124 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
125 self.0.serialize(serializer)
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::{Description, ExtensionName, Identifier};
136 use crate::decode_job_template;
137 use crate::CallerLimits;
138
139 fn yaml_val(s: &str) -> serde_json::Value {
140 serde_saphyr::from_str(s).unwrap()
141 }
142
143 fn decode_ok(s: &str) {
144 let v = yaml_val(s);
145 decode_job_template(v, None, &CallerLimits::default())
146 .unwrap_or_else(|_| panic!("Expected success for: {s}"));
147 }
148
149 fn check_err(s: &str, expected: &[&str]) {
151 let v = yaml_val(s);
152 let err = decode_job_template(v, None, &CallerLimits::default())
153 .expect_err(&format!("Expected error for: {s}"));
154 let msg = err.to_string();
155 for line in expected {
156 assert!(
157 msg.contains(line),
158 "Missing in error output: {line:?}\nGot:\n{msg}"
159 );
160 }
161 }
162
163 fn check_serde_err(s: &str, expected: &[&str]) {
165 let v = yaml_val(s);
166 let err = decode_job_template(v, None, &CallerLimits::default())
167 .expect_err(&format!("Expected error for: {s}"));
168 let msg = err.to_string();
169 for line in expected {
170 assert!(
171 msg.contains(line),
172 "Missing in error output: {line:?}\nGot:\n{msg}"
173 );
174 }
175 }
176
177 #[test]
182 fn identifier_valid_simple() {
183 Identifier::new("Foo").unwrap();
184 Identifier::new("_foo").unwrap();
185 Identifier::new("foo_bar").unwrap();
186 Identifier::new("A").unwrap();
187 Identifier::new("a1").unwrap();
188 }
189
190 #[test]
191 fn identifier_shortest_upper_a() {
192 Identifier::new("A").unwrap();
193 }
194
195 #[test]
196 fn identifier_longest_upper_a() {
197 Identifier::new(&"A".repeat(64)).unwrap();
198 }
199
200 #[test]
201 fn identifier_shortest_lower_a() {
202 Identifier::new("a").unwrap();
203 }
204
205 #[test]
206 fn identifier_longest_lower_a() {
207 Identifier::new(&"a".repeat(64)).unwrap();
208 }
209
210 #[test]
211 fn identifier_trailing_digits() {
212 Identifier::new(&format!("A{}", "0".repeat(63))).unwrap();
213 Identifier::new(&format!("A{}", "9".repeat(63))).unwrap();
214 }
215
216 #[test]
217 fn identifier_all_underscores() {
218 Identifier::new(&"_".repeat(64)).unwrap();
219 }
220
221 #[test]
222 fn identifier_65_chars_succeeds_at_type_level() {
223 Identifier::new(&"a".repeat(65)).unwrap();
226 }
227
228 #[test]
229 fn identifier_512_chars_succeeds_at_type_level() {
230 Identifier::new(&"a".repeat(512)).unwrap();
231 }
232
233 #[test]
234 fn identifier_513_chars_fails() {
235 let err = Identifier::new(&"a".repeat(513)).unwrap_err();
236 assert!(
237 err.to_string()
238 .contains("Identifier length must be 1..=512, got 513"),
239 "Got: {err}"
240 );
241 }
242
243 #[test]
244 fn identifier_empty() {
245 let err = Identifier::new("").unwrap_err();
246 assert!(
247 err.to_string()
248 .contains("Identifier length must be 1..=512, got 0"),
249 "Got: {err}"
250 );
251 }
252
253 #[test]
254 fn identifier_starts_with_digit_0() {
255 let err = Identifier::new("0").unwrap_err();
256 assert!(
257 err.to_string().contains("does not match pattern"),
258 "Got: {err}"
259 );
260 }
261
262 #[test]
263 fn identifier_starts_with_digit_9() {
264 let err = Identifier::new("9").unwrap_err();
265 assert!(
266 err.to_string().contains("does not match pattern"),
267 "Got: {err}"
268 );
269 }
270
271 #[test]
272 fn identifier_leading_space() {
273 let err = Identifier::new(" a").unwrap_err();
274 assert!(
275 err.to_string().contains("does not match pattern"),
276 "Got: {err}"
277 );
278 }
279
280 #[test]
281 fn identifier_trailing_space() {
282 let err = Identifier::new("a ").unwrap_err();
283 assert!(
284 err.to_string().contains("does not match pattern"),
285 "Got: {err}"
286 );
287 }
288
289 #[test]
290 fn identifier_starts_with_bang() {
291 let err = Identifier::new("!foo").unwrap_err();
292 assert!(
293 err.to_string().contains("does not match pattern"),
294 "Got: {err}"
295 );
296 }
297
298 #[test]
299 fn identifier_only_alphanum_bang() {
300 let err = Identifier::new("F!").unwrap_err();
301 assert!(
302 err.to_string().contains("does not match pattern"),
303 "Got: {err}"
304 );
305 }
306
307 #[test]
308 fn identifier_with_hyphen() {
309 let err = Identifier::new("foo-bar").unwrap_err();
310 assert!(
311 err.to_string().contains("does not match pattern"),
312 "Got: {err}"
313 );
314 }
315
316 #[test]
317 fn identifier_with_dot() {
318 let err = Identifier::new("foo.bar").unwrap_err();
319 assert!(
320 err.to_string().contains("does not match pattern"),
321 "Got: {err}"
322 );
323 }
324
325 #[test]
327 fn identifier_disallowed_chars() {
328 for ch in "!\"#$%&'()*+,-./:;<=>?@[\\]^`{|}~ \t\n\r".chars() {
329 let s = format!("a{ch}");
330 let err = Identifier::new(&s).unwrap_err();
331 assert!(
332 err.to_string().contains("does not match pattern"),
333 "Expected pattern error for char {ch:?}, got: {err}"
334 );
335 }
336 }
337
338 #[test]
343 fn description_min_length() {
344 Description::new("A").unwrap();
345 }
346
347 #[test]
348 fn description_max_length() {
349 Description::new(&"A".repeat(2048)).unwrap();
350 }
351
352 #[test]
353 fn description_empty_ok() {
354 Description::new("").unwrap();
355 }
356
357 #[test]
358 fn description_with_newlines_tabs() {
359 Description::new("With\nnewlines\nand\ttabs").unwrap();
360 }
361
362 #[test]
363 fn description_printable_ranges() {
364 Description::new("\u{0020}").unwrap(); Description::new("\u{007e}").unwrap(); Description::new("\u{00a0}").unwrap(); }
368
369 #[test]
370 fn description_too_long() {
371 let err = Description::new(&"a".repeat(2049)).unwrap_err();
372 assert!(
373 err.to_string().contains("exceeds 2048 characters"),
374 "Got: {err}"
375 );
376 }
377
378 #[test]
379 fn description_control_char_null() {
380 let err = Description::new("\u{0000}").unwrap_err();
381 assert!(err.to_string().contains("control characters"), "Got: {err}");
382 }
383
384 #[test]
385 fn description_control_char_1f() {
386 let err = Description::new("\u{001f}").unwrap_err();
387 assert!(err.to_string().contains("control characters"), "Got: {err}");
388 }
389
390 #[test]
391 fn description_control_char_del() {
392 let err = Description::new("\u{007f}").unwrap_err();
393 assert!(err.to_string().contains("control characters"), "Got: {err}");
394 }
395
396 #[test]
397 fn description_control_char_9f() {
398 let err = Description::new("\u{009f}").unwrap_err();
399 assert!(err.to_string().contains("control characters"), "Got: {err}");
400 }
401
402 #[test]
403 fn description_disallowed_after_newline() {
404 let err = Description::new("a\n\u{0000}").unwrap_err();
405 assert!(err.to_string().contains("control characters"), "Got: {err}");
406 }
407
408 #[test]
413 fn extension_name_valid() {
414 ExtensionName::new("EXPR").unwrap();
415 ExtensionName::new("FEATURE_BUNDLE_1").unwrap();
416 ExtensionName::new("ABC").unwrap();
417 ExtensionName::new("123").unwrap();
418 ExtensionName::new("A_B").unwrap();
419 }
420
421 #[test]
422 fn extension_name_too_short() {
423 let err = ExtensionName::new("AB").unwrap_err();
424 assert!(
425 err.to_string().contains("does not match pattern"),
426 "Got: {err}"
427 );
428 }
429
430 #[test]
431 fn extension_name_single_char() {
432 let err = ExtensionName::new("A").unwrap_err();
433 assert!(
434 err.to_string().contains("does not match pattern"),
435 "Got: {err}"
436 );
437 }
438
439 #[test]
440 fn extension_name_empty() {
441 let err = ExtensionName::new("").unwrap_err();
442 assert!(
443 err.to_string().contains("does not match pattern"),
444 "Got: {err}"
445 );
446 }
447
448 #[test]
449 fn extension_name_lowercase() {
450 let err = ExtensionName::new("expr").unwrap_err();
451 assert!(
452 err.to_string().contains("does not match pattern"),
453 "Got: {err}"
454 );
455 }
456
457 #[test]
458 fn extension_name_mixed_case() {
459 let err = ExtensionName::new("ExPr").unwrap_err();
460 assert!(
461 err.to_string().contains("does not match pattern"),
462 "Got: {err}"
463 );
464 }
465
466 #[test]
467 fn extension_name_with_spaces() {
468 let err = ExtensionName::new("FOO BAR").unwrap_err();
469 assert!(
470 err.to_string().contains("does not match pattern"),
471 "Got: {err}"
472 );
473 }
474
475 #[test]
476 fn extension_name_with_hyphen() {
477 let err = ExtensionName::new("FOO-BAR").unwrap_err();
478 assert!(
479 err.to_string().contains("does not match pattern"),
480 "Got: {err}"
481 );
482 }
483
484 #[test]
491 fn job_name_valid_shortest() {
492 decode_ok(
493 r#"{
494 "specificationVersion": "jobtemplate-2023-09",
495 "name": "A",
496 "steps": [{"name": "S", "script": {"actions": {"onRun": {"command": "foo"}}}}]
497 }"#,
498 );
499 }
500
501 #[test]
502 fn job_name_empty() {
503 check_err(
504 r#"{
505 "specificationVersion": "jobtemplate-2023-09",
506 "name": "",
507 "steps": [{"name": "S", "script": {"actions": {"onRun": {"command": "foo"}}}}]
508 }"#,
509 &["name:\n\tmust not be empty."],
510 );
511 }
512
513 #[test]
514 fn job_name_too_long() {
515 let name = "a".repeat(513);
516 let tmpl = format!(
517 r#"{{
518 "specificationVersion": "jobtemplate-2023-09",
519 "name": "{name}",
520 "steps": [{{"name": "S", "script": {{"actions": {{"onRun": {{"command": "foo"}}}}}}}}]
521 }}"#
522 );
523 check_err(&tmpl, &["name:\n\texceeds 128 characters."]);
524 }
525
526 #[test]
527 fn job_name_with_control_char_null() {
528 check_err(
529 r#"{
530 "specificationVersion": "jobtemplate-2023-09",
531 "name": "a\u0000b",
532 "steps": [{"name": "S", "script": {"actions": {"onRun": {"command": "foo"}}}}]
533 }"#,
534 &["name:\n\tcontains control characters."],
535 );
536 }
537
538 #[test]
539 fn job_name_with_control_char_1f() {
540 check_err(
541 r#"{
542 "specificationVersion": "jobtemplate-2023-09",
543 "name": "Job\u001fName",
544 "steps": [{"name": "S", "script": {"actions": {"onRun": {"command": "foo"}}}}]
545 }"#,
546 &["name:\n\tcontains control characters."],
547 );
548 }
549
550 #[test]
551 fn job_name_with_control_char_del() {
552 check_err(
553 r#"{
554 "specificationVersion": "jobtemplate-2023-09",
555 "name": "a\u007fb",
556 "steps": [{"name": "S", "script": {"actions": {"onRun": {"command": "foo"}}}}]
557 }"#,
558 &["name:\n\tcontains control characters."],
559 );
560 }
561
562 #[test]
563 fn job_name_with_control_char_9f() {
564 check_err(
565 r#"{
566 "specificationVersion": "jobtemplate-2023-09",
567 "name": "a\u009fb",
568 "steps": [{"name": "S", "script": {"actions": {"onRun": {"command": "foo"}}}}]
569 }"#,
570 &["name:\n\tcontains control characters."],
571 );
572 }
573
574 #[test]
575 fn job_name_with_newline() {
576 check_err(
577 r#"{
578 "specificationVersion": "jobtemplate-2023-09",
579 "name": "a\nb",
580 "steps": [{"name": "S", "script": {"actions": {"onRun": {"command": "foo"}}}}]
581 }"#,
582 &["name:\n\tcontains control characters."],
583 );
584 }
585
586 #[test]
589 fn step_name_valid_shortest() {
590 decode_ok(
591 r#"{
592 "specificationVersion": "jobtemplate-2023-09",
593 "name": "Job",
594 "steps": [{"name": "A", "script": {"actions": {"onRun": {"command": "foo"}}}}]
595 }"#,
596 );
597 }
598
599 #[test]
600 fn step_name_empty() {
601 check_err(
602 r#"{
603 "specificationVersion": "jobtemplate-2023-09",
604 "name": "Job",
605 "steps": [{"name": "", "script": {"actions": {"onRun": {"command": "foo"}}}}]
606 }"#,
607 &["steps[0] -> name:\n\tmust not be empty."],
608 );
609 }
610
611 #[test]
612 fn step_name_too_long() {
613 let name = "a".repeat(513);
614 let tmpl = format!(
615 r#"{{
616 "specificationVersion": "jobtemplate-2023-09",
617 "name": "Job",
618 "steps": [{{"name": "{name}", "script": {{"actions": {{"onRun": {{"command": "foo"}}}}}}}}]
619 }}"#
620 );
621 check_err(&tmpl, &["steps[0] -> name:\n\texceeds 64 characters."]);
622 }
623
624 #[test]
625 fn step_name_with_control_char() {
626 check_err(
627 r#"{
628 "specificationVersion": "jobtemplate-2023-09",
629 "name": "Job",
630 "steps": [{"name": "Step\u001fName", "script": {"actions": {"onRun": {"command": "foo"}}}}]
631 }"#,
632 &["steps[0] -> name:\n\tcontains control characters."],
633 );
634 }
635
636 #[test]
639 fn env_name_with_control_char() {
640 check_err(
641 r#"{
642 "specificationVersion": "jobtemplate-2023-09",
643 "name": "Job",
644 "steps": [{"name": "S", "script": {"actions": {"onRun": {"command": "foo"}}}}],
645 "jobEnvironments": [{"name": "Env\u001fName", "script": {"actions": {"onEnter": {"command": "foo"}}}}]
646 }"#,
647 &["jobEnvironments[0] -> name:\n\tcontains control characters."],
648 );
649 }
650
651 #[test]
654 fn command_empty() {
655 check_err(
656 r#"{
657 "specificationVersion": "jobtemplate-2023-09",
658 "name": "Job",
659 "steps": [{"name": "S", "script": {"actions": {"onRun": {"command": ""}}}}]
660 }"#,
661 &["steps[0] -> script -> actions -> onRun -> command:\n\tmust not be empty."],
662 );
663 }
664
665 #[test]
668 fn description_with_newlines_in_template_ok() {
669 decode_ok(
670 r#"{
671 "specificationVersion": "jobtemplate-2023-09",
672 "name": "Job",
673 "description": "Line1\nLine2\nLine3",
674 "steps": [{"name": "S", "script": {"actions": {"onRun": {"command": "foo"}}}}]
675 }"#,
676 );
677 }
678
679 #[test]
680 fn description_too_long_in_template() {
681 let desc = "a".repeat(2049);
682 let tmpl = format!(
683 r#"{{
684 "specificationVersion": "jobtemplate-2023-09",
685 "name": "Job",
686 "description": "{desc}",
687 "steps": [{{"name": "S", "script": {{"actions": {{"onRun": {{"command": "foo"}}}}}}}}]
688 }}"#
689 );
690 check_serde_err(&tmpl, &["Description exceeds 2048 characters"]);
691 }
692
693 #[test]
694 fn description_control_char_null_in_template() {
695 check_serde_err(
696 r#"{
697 "specificationVersion": "jobtemplate-2023-09",
698 "name": "Job",
699 "description": "\u0000",
700 "steps": [{"name": "S", "script": {"actions": {"onRun": {"command": "foo"}}}}]
701 }"#,
702 &["Description contains control characters"],
703 );
704 }
705
706 #[test]
707 fn description_control_char_1f_in_template() {
708 check_serde_err(
709 r#"{
710 "specificationVersion": "jobtemplate-2023-09",
711 "name": "Job",
712 "description": "\u001f",
713 "steps": [{"name": "S", "script": {"actions": {"onRun": {"command": "foo"}}}}]
714 }"#,
715 &["Description contains control characters"],
716 );
717 }
718
719 #[test]
720 fn description_control_char_del_in_template() {
721 check_serde_err(
722 r#"{
723 "specificationVersion": "jobtemplate-2023-09",
724 "name": "Job",
725 "description": "\u007f",
726 "steps": [{"name": "S", "script": {"actions": {"onRun": {"command": "foo"}}}}]
727 }"#,
728 &["Description contains control characters"],
729 );
730 }
731
732 #[test]
733 fn description_control_char_9f_in_template() {
734 check_serde_err(
735 r#"{
736 "specificationVersion": "jobtemplate-2023-09",
737 "name": "Job",
738 "description": "\u009f",
739 "steps": [{"name": "S", "script": {"actions": {"onRun": {"command": "foo"}}}}]
740 }"#,
741 &["Description contains control characters"],
742 );
743 }
744
745 #[test]
748 fn identifier_empty_in_template() {
749 check_serde_err(
750 r#"{
751 "specificationVersion": "jobtemplate-2023-09",
752 "name": "Test",
753 "steps": [{"name": "S", "parameterSpace": {"taskParameterDefinitions": [{"name": "", "type": "INT", "range": [1]}]}, "script": {"actions": {"onRun": {"command": "foo"}}}}]
754 }"#,
755 &["Identifier length must be 1..=512, got 0"],
756 );
757 }
758
759 #[test]
760 fn identifier_starts_with_digit_in_template() {
761 check_serde_err(
762 r#"{
763 "specificationVersion": "jobtemplate-2023-09",
764 "name": "Test",
765 "steps": [{"name": "S", "parameterSpace": {"taskParameterDefinitions": [{"name": "1foo", "type": "INT", "range": [1]}]}, "script": {"actions": {"onRun": {"command": "foo"}}}}]
766 }"#,
767 &["does not match pattern"],
768 );
769 }
770
771 #[test]
772 fn identifier_with_hyphen_in_template() {
773 check_serde_err(
774 r#"{
775 "specificationVersion": "jobtemplate-2023-09",
776 "name": "Test",
777 "steps": [{"name": "S", "parameterSpace": {"taskParameterDefinitions": [{"name": "foo-bar", "type": "INT", "range": [1]}]}, "script": {"actions": {"onRun": {"command": "foo"}}}}]
778 }"#,
779 &["does not match pattern"],
780 );
781 }
782
783 #[test]
784 fn identifier_with_bang_in_template() {
785 check_serde_err(
786 r#"{
787 "specificationVersion": "jobtemplate-2023-09",
788 "name": "Test",
789 "steps": [{"name": "S", "parameterSpace": {"taskParameterDefinitions": [{"name": "F!", "type": "INT", "range": [1]}]}, "script": {"actions": {"onRun": {"command": "foo"}}}}]
790 }"#,
791 &["does not match pattern"],
792 );
793 }
794
795 #[test]
796 fn identifier_leading_space_in_template() {
797 check_serde_err(
798 r#"{
799 "specificationVersion": "jobtemplate-2023-09",
800 "name": "Test",
801 "steps": [{"name": "S", "parameterSpace": {"taskParameterDefinitions": [{"name": " a", "type": "INT", "range": [1]}]}, "script": {"actions": {"onRun": {"command": "foo"}}}}]
802 }"#,
803 &["does not match pattern"],
804 );
805 }
806
807 #[test]
809 fn identifier_too_long_in_template() {
810 let name = "a".repeat(65);
811 let tmpl = format!(
812 r#"{{
813 "specificationVersion": "jobtemplate-2023-09",
814 "name": "Test",
815 "steps": [{{"name": "S", "parameterSpace": {{"taskParameterDefinitions": [{{"name": "{name}", "type": "INT", "range": [1]}}]}}, "script": {{"actions": {{"onRun": {{"command": "foo"}}}}}}}}]
816 }}"#
817 );
818 check_err(&tmpl, &[
819 "steps[0] -> parameterSpace -> taskParameterDefinitions[0]:\n\tname exceeds 64 characters.",
820 ]);
821 }
822
823 #[test]
826 fn extension_name_lowercase_in_template() {
827 check_serde_err(
828 r#"{
829 "specificationVersion": "jobtemplate-2023-09",
830 "name": "Test",
831 "steps": [{"name": "S", "script": {"actions": {"onRun": {"command": "foo"}}}}],
832 "extensions": ["expr"]
833 }"#,
834 &["does not match pattern"],
835 );
836 }
837
838 #[test]
839 fn extension_name_too_short_in_template() {
840 check_serde_err(
841 r#"{
842 "specificationVersion": "jobtemplate-2023-09",
843 "name": "Test",
844 "steps": [{"name": "S", "script": {"actions": {"onRun": {"command": "foo"}}}}],
845 "extensions": ["AB"]
846 }"#,
847 &["does not match pattern"],
848 );
849 }
850
851 #[test]
852 fn extension_name_with_spaces_in_template() {
853 check_serde_err(
854 r#"{
855 "specificationVersion": "jobtemplate-2023-09",
856 "name": "Test",
857 "steps": [{"name": "S", "script": {"actions": {"onRun": {"command": "foo"}}}}],
858 "extensions": ["FOO BAR"]
859 }"#,
860 &["does not match pattern"],
861 );
862 }
863
864 #[test]
867 fn param_name_empty_in_template() {
868 check_serde_err(
869 r#"{
870 "specificationVersion": "jobtemplate-2023-09",
871 "name": "Test",
872 "parameterDefinitions": [{"name": "", "type": "INT"}],
873 "steps": [{"name": "S", "script": {"actions": {"onRun": {"command": "foo"}}}}]
874 }"#,
875 &["Identifier length must be 1..=512, got 0"],
876 );
877 }
878
879 #[test]
880 fn param_name_starts_with_digit_in_template() {
881 check_serde_err(
882 r#"{
883 "specificationVersion": "jobtemplate-2023-09",
884 "name": "Test",
885 "parameterDefinitions": [{"name": "0foo", "type": "INT"}],
886 "steps": [{"name": "S", "script": {"actions": {"onRun": {"command": "foo"}}}}]
887 }"#,
888 &["does not match pattern"],
889 );
890 }
891
892 #[test]
893 fn description_max_length_multibyte_unicode() {
894 let desc: String = std::iter::repeat_n('一', 2048).collect();
897 assert_eq!(desc.chars().count(), 2048);
898 Description::new(&desc).unwrap();
899 }
900
901 #[test]
902 fn description_too_long_multibyte_unicode() {
903 let desc: String = std::iter::repeat_n('一', 2049).collect();
904 let err = Description::new(&desc).unwrap_err();
905 assert!(
906 err.to_string().contains("exceeds 2048 characters"),
907 "Got: {err}"
908 );
909 }
910}