Skip to main content

openjd_model/template/
constrained_strings.rs

1// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2// Copyright by contributors to this project.
3// SPDX-License-Identifier: (Apache-2.0 OR MIT)
4
5//! Constrained string types per spec §7.
6
7use crate::error::ModelError;
8use regex::Regex;
9use serde::de::{self, Deserializer};
10use std::sync::LazyLock;
11
12/// §7.1 Identifier: `[A-Za-z_][A-Za-z0-9_]*`, length 1..=512 (64 base, 512 with FEATURE_BUNDLE_1)
13#[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/// §7.2 Description: any unicode except Cc category, length 0..=2048
60#[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/// §1.1.2 ExtensionName: `[A-Z_0-9]{3,128}`
95#[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    //! Tests ported from Python test/openjd/model/v2023_09/test_strings.py
132    //!
133    //! Gold standard: failure tests assert the full error message including path.
134
135    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    /// For validation-pipeline errors (path + message format).
150    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    /// For serde-level errors (rejected during deserialization).
164    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    // ══════════════════════════════════════════════════════════════
178    // Identifier — unit-level tests via Identifier::new()
179    // ══════════════════════════════════════════════════════════════
180
181    #[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        // 65 chars is within the 512 max that Identifier::new() allows.
224        // The tighter 64-char limit is enforced in the validation pass.
225        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 disallowed printable characters (from Python's parametrized list)
326    #[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    // ══════════════════════════════════════════════════════════════
339    // Description — unit-level tests via Description::new()
340    // ══════════════════════════════════════════════════════════════
341
342    #[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(); // start of first printable range
365        Description::new("\u{007e}").unwrap(); // end of first printable range
366        Description::new("\u{00a0}").unwrap(); // start of second printable range
367    }
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    // ══════════════════════════════════════════════════════════════
409    // ExtensionName — unit-level tests via ExtensionName::new()
410    // ══════════════════════════════════════════════════════════════
411
412    #[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    // ══════════════════════════════════════════════════════════════
485    // String constraints through job template — validation pipeline
486    // ══════════════════════════════════════════════════════════════
487
488    // --- Job name ---
489
490    #[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    // --- Step name ---
587
588    #[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    // --- Environment name ---
637
638    #[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    // --- Command ---
652
653    #[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    // --- Description through template ---
666
667    #[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    // --- Identifier through template (serde-level via task parameter name) ---
746
747    #[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    // Identifier exceeds 64 chars — validation pipeline error
808    #[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    // --- ExtensionName through template (serde-level) ---
824
825    #[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    // --- Identifier through job parameter name (serde-level) ---
865
866    #[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        // 2048 CJK characters (3 bytes each in UTF-8) must be accepted.
895        // The limit is 2048 characters, not 2048 bytes.
896        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}