Skip to main content

openjd_model/
types.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//! Core types shared across specification versions.
6//!
7//! Mirrors Python `_types.py`: SpecificationRevision, ParameterValueType,
8//! ParameterValue, TemplateSpecificationVersion, etc.
9
10use std::collections::HashMap;
11use std::fmt;
12
13use indexmap::IndexMap;
14use openjd_expr::ExprType;
15use serde::{Deserialize, Serialize};
16
17// ── String-typed enums for compile-time safety ──
18
19/// §6 Embedded file type.
20///
21/// Marked `#[non_exhaustive]` so that future revisions or extensions
22/// can add new file types (for example, a `Binary` variant, which has
23/// been reserved space in the spec since RFC 0001 discussion) without
24/// a SemVer break for downstream crates that match on this enum.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
27#[non_exhaustive]
28pub enum FileType {
29    Text,
30}
31
32impl fmt::Display for FileType {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            Self::Text => write!(f, "TEXT"),
36        }
37    }
38}
39
40/// End-of-line mode for embedded files (FEATURE_BUNDLE_1).
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
43pub enum EndOfLine {
44    Lf,
45    Crlf,
46    Auto,
47}
48
49impl fmt::Display for EndOfLine {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match self {
52            Self::Lf => write!(f, "LF"),
53            Self::Crlf => write!(f, "CRLF"),
54            Self::Auto => write!(f, "AUTO"),
55        }
56    }
57}
58
59/// §2.2 PATH parameter objectType.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
62pub enum ObjectType {
63    File,
64    Directory,
65}
66
67impl fmt::Display for ObjectType {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        match self {
70            Self::File => write!(f, "FILE"),
71            Self::Directory => write!(f, "DIRECTORY"),
72        }
73    }
74}
75
76/// §2.2 PATH parameter dataFlow.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
79pub enum DataFlow {
80    None,
81    In,
82    Out,
83    Inout,
84}
85
86impl fmt::Display for DataFlow {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        match self {
89            Self::None => write!(f, "NONE"),
90            Self::In => write!(f, "IN"),
91            Self::Out => write!(f, "OUT"),
92            Self::Inout => write!(f, "INOUT"),
93        }
94    }
95}
96
97/// Specification revision identifier.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
99#[non_exhaustive]
100pub enum SpecificationRevision {
101    V2023_09,
102}
103
104impl fmt::Display for SpecificationRevision {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        match self {
107            Self::V2023_09 => write!(f, "2023-09"),
108        }
109    }
110}
111
112/// Template specification version strings (the `specificationVersion` field value).
113///
114/// `#[non_exhaustive]` because future revisions will add new variants
115/// (e.g., `JobTemplate2027_XX`, `Environment2027_XX`). Adding a variant
116/// must not be a breaking change for downstream crates.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
118#[non_exhaustive]
119pub enum TemplateSpecificationVersion {
120    JobTemplate2023_09,
121    Environment2023_09,
122}
123
124impl TemplateSpecificationVersion {
125    pub fn as_str(&self) -> &'static str {
126        match self {
127            Self::JobTemplate2023_09 => "jobtemplate-2023-09",
128            Self::Environment2023_09 => "environment-2023-09",
129        }
130    }
131
132    pub fn is_job_template(&self) -> bool {
133        matches!(self, Self::JobTemplate2023_09)
134    }
135
136    pub fn is_environment_template(&self) -> bool {
137        matches!(self, Self::Environment2023_09)
138    }
139
140    pub fn revision(&self) -> SpecificationRevision {
141        match self {
142            Self::JobTemplate2023_09 | Self::Environment2023_09 => SpecificationRevision::V2023_09,
143        }
144    }
145}
146
147impl std::str::FromStr for TemplateSpecificationVersion {
148    type Err = String;
149
150    fn from_str(s: &str) -> Result<Self, Self::Err> {
151        match s {
152            "jobtemplate-2023-09" => Ok(Self::JobTemplate2023_09),
153            "environment-2023-09" => Ok(Self::Environment2023_09),
154            _ => Err(format!("unknown specification version: '{s}'")),
155        }
156    }
157}
158
159/// The type of a job parameter definition.
160///
161/// Marked `#[non_exhaustive]` so that future revisions and extensions can
162/// add parameter types (as RFC 0007 already did for `Bool`, `RangeExpr`,
163/// and the `List[…]` family) without a SemVer break.
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
165#[non_exhaustive]
166pub enum JobParameterType {
167    String,
168    Int,
169    Float,
170    Path,
171    Bool,
172    RangeExpr,
173    ListString,
174    ListInt,
175    ListFloat,
176    ListPath,
177    ListBool,
178    ListListInt,
179}
180
181impl JobParameterType {
182    /// Parse from the spec string (case-insensitive).
183    pub fn from_spec_str(s: &str) -> Option<Self> {
184        let upper = s.to_ascii_uppercase();
185        match upper.as_str() {
186            "STRING" => Some(Self::String),
187            "INT" => Some(Self::Int),
188            "FLOAT" => Some(Self::Float),
189            "PATH" => Some(Self::Path),
190            "BOOL" => Some(Self::Bool),
191            "RANGE_EXPR" => Some(Self::RangeExpr),
192            "LIST[STRING]" => Some(Self::ListString),
193            "LIST[INT]" => Some(Self::ListInt),
194            "LIST[FLOAT]" => Some(Self::ListFloat),
195            "LIST[PATH]" => Some(Self::ListPath),
196            "LIST[BOOL]" => Some(Self::ListBool),
197            "LIST[LIST[INT]]" => Some(Self::ListListInt),
198            _ => None,
199        }
200    }
201
202    /// Returns the canonical spec string.
203    pub fn as_spec_str(&self) -> &'static str {
204        match self {
205            Self::String => "STRING",
206            Self::Int => "INT",
207            Self::Float => "FLOAT",
208            Self::Path => "PATH",
209            Self::Bool => "BOOL",
210            Self::RangeExpr => "RANGE_EXPR",
211            Self::ListString => "LIST[STRING]",
212            Self::ListInt => "LIST[INT]",
213            Self::ListFloat => "LIST[FLOAT]",
214            Self::ListPath => "LIST[PATH]",
215            Self::ListBool => "LIST[BOOL]",
216            Self::ListListInt => "LIST[LIST[INT]]",
217        }
218    }
219
220    /// Returns the `ExprType` this parameter produces in the symbol table.
221    pub fn expr_type(&self) -> ExprType {
222        match self {
223            Self::String => ExprType::STRING,
224            Self::Int => ExprType::INT,
225            Self::Float => ExprType::FLOAT,
226            Self::Path => ExprType::PATH,
227            Self::Bool => ExprType::BOOL,
228            Self::RangeExpr => ExprType::RANGE_EXPR,
229            Self::ListString => ExprType::list(ExprType::STRING),
230            Self::ListInt => ExprType::list(ExprType::INT),
231            Self::ListFloat => ExprType::list(ExprType::FLOAT),
232            Self::ListPath => ExprType::list(ExprType::PATH),
233            Self::ListBool => ExprType::list(ExprType::BOOL),
234            Self::ListListInt => ExprType::list(ExprType::list(ExprType::INT)),
235        }
236    }
237}
238
239impl fmt::Display for JobParameterType {
240    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
241        f.write_str(self.as_spec_str())
242    }
243}
244
245/// The type of a task parameter definition.
246///
247/// Marked `#[non_exhaustive]` so that future revisions and extensions
248/// can add task parameter types (for example, a list-typed task
249/// parameter analogous to `JobParameterType::ListInt`, or additional
250/// chunked variants) without a SemVer break.
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
252#[non_exhaustive]
253pub enum TaskParameterType {
254    Int,
255    Float,
256    String,
257    Path,
258    ChunkInt,
259}
260
261impl TaskParameterType {
262    /// Parse from the spec string (case-insensitive).
263    pub fn from_spec_str(s: &str) -> Option<Self> {
264        let upper = s.to_ascii_uppercase();
265        match upper.as_str() {
266            "INT" => Some(Self::Int),
267            "FLOAT" => Some(Self::Float),
268            "STRING" => Some(Self::String),
269            "PATH" => Some(Self::Path),
270            "CHUNK[INT]" => Some(Self::ChunkInt),
271            _ => None,
272        }
273    }
274
275    /// Returns the canonical spec string.
276    pub fn as_spec_str(&self) -> &'static str {
277        match self {
278            Self::Int => "INT",
279            Self::Float => "FLOAT",
280            Self::String => "STRING",
281            Self::Path => "PATH",
282            Self::ChunkInt => "CHUNK[INT]",
283        }
284    }
285
286    /// Returns the `ExprType` this parameter produces in the symbol table.
287    pub fn expr_type(&self) -> ExprType {
288        match self {
289            Self::Int => ExprType::INT,
290            Self::Float => ExprType::FLOAT,
291            Self::String => ExprType::STRING,
292            Self::Path => ExprType::PATH,
293            Self::ChunkInt => ExprType::RANGE_EXPR,
294        }
295    }
296}
297
298impl fmt::Display for TaskParameterType {
299    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
300        f.write_str(self.as_spec_str())
301    }
302}
303
304/// A processed job parameter value.
305#[derive(Debug, Clone)]
306pub struct JobParameterValue {
307    pub param_type: JobParameterType,
308    pub value: openjd_expr::ExprValue,
309}
310
311/// A processed task parameter value.
312#[derive(Debug, Clone)]
313pub struct TaskParameterValue {
314    pub param_type: TaskParameterType,
315    pub value: openjd_expr::ExprValue,
316}
317
318/// Input parameter values from the user (name → value).
319///
320/// Values are `ExprValue` so callers can pass native types directly:
321/// - CLI callers pass `ExprValue::String("42".into())` for everything and
322///   let `preprocess_job_parameters` coerce to the target type.
323/// - Library callers can pass typed values like `ExprValue::Int(42)` or
324///   `ExprValue::ListInt(vec![1, 2, 3])` directly.
325pub type JobParameterInputValues = HashMap<String, openjd_expr::ExprValue>;
326
327/// Processed job parameter values (name → typed value).
328pub type JobParameterValues = HashMap<String, JobParameterValue>;
329
330/// A single task's parameter values.
331pub type TaskParameterSet = IndexMap<String, TaskParameterValue>;
332
333/// Set of extensions enabled for a template.
334pub type Extensions = std::collections::HashSet<ModelExtension>;
335
336/// Extension variants recognized by `openjd-model` for the 2023-09
337/// specification revision.
338///
339/// Template `extensions` lists are parsed into `ModelExtension`
340/// values; unrecognized strings produce a `FromStr` error at the parse
341/// boundary, so once an `Extensions` set has been constructed every
342/// element is guaranteed to be a known extension.
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
344#[non_exhaustive]
345pub enum ModelExtension {
346    TaskChunking,
347    RedactedEnvVars,
348    FeatureBundle1,
349    Expr,
350}
351
352impl ModelExtension {
353    /// All extension variants, in a stable order for iteration and
354    /// for building default "enable all" allowlists.
355    pub const ALL: &'static [ModelExtension] = &[
356        Self::TaskChunking,
357        Self::RedactedEnvVars,
358        Self::FeatureBundle1,
359        Self::Expr,
360    ];
361
362    pub fn as_str(&self) -> &'static str {
363        match self {
364            Self::TaskChunking => "TASK_CHUNKING",
365            Self::RedactedEnvVars => "REDACTED_ENV_VARS",
366            Self::FeatureBundle1 => "FEATURE_BUNDLE_1",
367            Self::Expr => "EXPR",
368        }
369    }
370}
371
372impl std::str::FromStr for ModelExtension {
373    type Err = String;
374    fn from_str(s: &str) -> Result<Self, Self::Err> {
375        match s {
376            "TASK_CHUNKING" => Ok(Self::TaskChunking),
377            "REDACTED_ENV_VARS" => Ok(Self::RedactedEnvVars),
378            "FEATURE_BUNDLE_1" => Ok(Self::FeatureBundle1),
379            "EXPR" => Ok(Self::Expr),
380            _ => Err(format!("Unknown extension: {s}")),
381        }
382    }
383}
384
385/// Serialize as the canonical UPPER_SNAKE_CASE extension name, so the
386/// transport form matches what appears in template YAML/JSON and in
387/// the Python implementation (e.g. `"EXPR"`, not `"Expr"`).
388impl serde::Serialize for ModelExtension {
389    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
390        serializer.serialize_str(self.as_str())
391    }
392}
393
394/// Caller-provided limits that layer on top of spec-defined limits.
395///
396/// These allow a service or application to impose additional restrictions
397/// beyond what the OpenJD specification requires. All fields are optional —
398/// `None` means "no additional restriction beyond the spec-defined limit."
399///
400/// Caller limits can only add restrictions, never relax spec-defined ones.
401#[derive(Debug, Clone, Default)]
402pub struct CallerLimits {
403    /// Maximum number of steps in a job template.
404    pub max_step_count: Option<usize>,
405    /// Maximum number of environments (job + all step environments combined)
406    /// in a job template.
407    pub max_env_count: Option<usize>,
408    /// Maximum total task count across all steps in a job template.
409    /// Checked after parameter space ranges are resolved in `create_job`.
410    pub max_task_count: Option<u64>,
411    /// Maximum JSON-encoded size of a step script, in bytes.
412    pub max_step_script_size: Option<usize>,
413    /// Maximum JSON-encoded size of an environment, in bytes.
414    pub max_environment_size: Option<usize>,
415    /// Maximum total template document size, in bytes.
416    pub max_template_size: Option<usize>,
417}
418
419/// Model-side profile: the specification revision plus the set of
420/// enabled extensions that together describe what features a template
421/// or job may use.
422///
423/// `ModelProfile` is the `openjd-model` counterpart of
424/// [`openjd_expr::ExprProfile`]. Both crates share the pattern:
425///
426/// - `openjd-expr`: [`ExprProfile`](openjd_expr::ExprProfile) drives
427///   [`FunctionLibrary::for_profile`](openjd_expr::FunctionLibrary::for_profile).
428///   Its third axis is [`HostContext`](openjd_expr::HostContext) — host-supplied
429///   runtime state (path mapping rules).
430/// - `openjd-model`: `ModelProfile` drives template validation and
431///   job creation. Caller policy is orthogonal and carried separately
432///   in [`CallerLimits`]; the two are bundled into a
433///   [`ValidationContext`] where both are needed together.
434///
435/// Profiles are small value types: clone them freely, store them on
436/// sessions, pass them by reference into validators. `ModelProfile`
437/// has no mutable operations other than builder-style `with_*`
438/// methods that return a new profile.
439///
440/// Use [`ModelProfile::to_expr_profile`] to derive a matching
441/// `ExprProfile` when calling into `openjd-expr`; the caller supplies
442/// the appropriate `HostContext` for their situation.
443#[derive(Debug, Clone)]
444pub struct ModelProfile {
445    revision: SpecificationRevision,
446    extensions: Extensions,
447}
448
449impl ModelProfile {
450    /// Build a profile for the given revision with no extensions enabled.
451    pub fn new(revision: SpecificationRevision) -> Self {
452        Self {
453            revision,
454            extensions: Extensions::new(),
455        }
456    }
457
458    /// Set the enabled extensions (replaces any existing set).
459    #[must_use]
460    pub fn with_extensions(mut self, extensions: Extensions) -> Self {
461        self.extensions = extensions;
462        self
463    }
464
465    /// The specification revision this profile targets.
466    pub fn revision(&self) -> SpecificationRevision {
467        self.revision
468    }
469
470    /// The set of extensions this profile enables.
471    pub fn extensions(&self) -> &Extensions {
472        &self.extensions
473    }
474
475    /// True iff `ext` is enabled in this profile.
476    pub fn has_extension(&self, ext: ModelExtension) -> bool {
477        self.extensions.contains(&ext)
478    }
479
480    /// Derive an [`ExprProfile`](openjd_expr::ExprProfile) matching this
481    /// profile's revision and extensions, with the caller-specified
482    /// [`HostContext`](openjd_expr::HostContext).
483    ///
484    /// This is the bridge from `openjd-model` to `openjd-expr`: call
485    /// this and pass the result to
486    /// [`FunctionLibrary::for_profile`](openjd_expr::FunctionLibrary::for_profile).
487    ///
488    /// The `host_context` argument is a caller responsibility because
489    /// the model has no opinion on it — template validation uses
490    /// [`HostContext::Unresolved`](openjd_expr::HostContext::Unresolved),
491    /// runtime session work uses
492    /// [`HostContext::WithRules`](openjd_expr::HostContext::WithRules),
493    /// and pure-template work (e.g. resolving the job name) uses
494    /// [`HostContext::None`](openjd_expr::HostContext::None).
495    pub fn to_expr_profile(
496        &self,
497        host_context: openjd_expr::HostContext,
498    ) -> openjd_expr::ExprProfile {
499        // Map this crate's SpecificationRevision onto openjd-expr's
500        // ExprRevision. The mapping is total today because both enums
501        // have a single variant each; future revisions will add arms
502        // here as both sides grow. The match on revision mirrors the
503        // pattern used in EffectiveLimits::from_context.
504        let revision = match self.revision {
505            SpecificationRevision::V2023_09 => openjd_expr::ExprRevision::V2026_02,
506        };
507        // `ExprExtension` is empty today — no expression-level
508        // extensions exist yet. Model-side `ModelExtension` variants
509        // gate *where* expressions are permitted in templates (EXPR,
510        // FEATURE_BUNDLE_1, TASK_CHUNKING, REDACTED_ENV_VARS), not
511        // which functions are registered once they are permitted, so
512        // the expression-level extension set is always empty for now.
513        let extensions = std::collections::HashSet::new();
514        openjd_expr::ExprProfile::new(revision)
515            .with_extensions(extensions)
516            .with_host_context(host_context)
517    }
518}
519
520/// Context for validation, carrying a [`ModelProfile`] and caller-policy
521/// [`CallerLimits`] as a single bundle.
522///
523/// Use this type when a function needs both the profile (revision +
524/// extensions) and the caller's policy overrides. When only the
525/// profile is needed, take `&ModelProfile` directly.
526#[derive(Debug, Clone)]
527pub struct ValidationContext {
528    pub profile: ModelProfile,
529    pub caller_limits: CallerLimits,
530}
531
532impl ValidationContext {
533    /// Build a context for the given revision with no extensions and
534    /// default caller limits.
535    pub fn new(revision: SpecificationRevision) -> Self {
536        Self {
537            profile: ModelProfile::new(revision),
538            caller_limits: CallerLimits::default(),
539        }
540    }
541
542    /// Build a context with the given revision + extensions and
543    /// default caller limits.
544    pub fn with_extensions(revision: SpecificationRevision, extensions: Extensions) -> Self {
545        Self {
546            profile: ModelProfile::new(revision).with_extensions(extensions),
547            caller_limits: CallerLimits::default(),
548        }
549    }
550
551    /// Build a context from an existing [`ModelProfile`], with default
552    /// caller limits.
553    pub fn from_profile(profile: ModelProfile) -> Self {
554        Self {
555            profile,
556            caller_limits: CallerLimits::default(),
557        }
558    }
559
560    /// Attach caller limits, consuming and returning `self`.
561    #[must_use]
562    pub fn with_caller_limits(mut self, caller_limits: CallerLimits) -> Self {
563        self.caller_limits = caller_limits;
564        self
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    const ALL_VERSIONS: &[TemplateSpecificationVersion] = &[
573        TemplateSpecificationVersion::JobTemplate2023_09,
574        TemplateSpecificationVersion::Environment2023_09,
575    ];
576
577    fn job_template_versions() -> Vec<TemplateSpecificationVersion> {
578        ALL_VERSIONS
579            .iter()
580            .copied()
581            .filter(|v| v.is_job_template())
582            .collect()
583    }
584
585    fn environment_template_versions() -> Vec<TemplateSpecificationVersion> {
586        ALL_VERSIONS
587            .iter()
588            .copied()
589            .filter(|v| v.is_environment_template())
590            .collect()
591    }
592
593    #[test]
594    fn test_all_values_classified() {
595        let job_versions: std::collections::HashSet<_> =
596            job_template_versions().into_iter().collect();
597        let env_versions: std::collections::HashSet<_> =
598            environment_template_versions().into_iter().collect();
599        // No overlap
600        assert!(job_versions.is_disjoint(&env_versions));
601        // Together they cover all versions
602        let all: std::collections::HashSet<_> = ALL_VERSIONS.iter().copied().collect();
603        let union: std::collections::HashSet<_> =
604            job_versions.union(&env_versions).copied().collect();
605        assert_eq!(union, all);
606    }
607
608    #[test]
609    fn test_job_template_versions() {
610        for v in job_template_versions() {
611            assert!(v.is_job_template(), "{:?} should be a job template", v);
612        }
613    }
614
615    #[test]
616    fn test_not_job_template_versions() {
617        for v in ALL_VERSIONS {
618            if !v.is_job_template() {
619                assert!(v.is_environment_template());
620            }
621        }
622    }
623
624    #[test]
625    fn test_environment_template_versions() {
626        for v in environment_template_versions() {
627            assert!(
628                v.is_environment_template(),
629                "{:?} should be an env template",
630                v
631            );
632        }
633    }
634
635    #[test]
636    fn test_not_environment_template_versions() {
637        for v in ALL_VERSIONS {
638            if !v.is_environment_template() {
639                assert!(v.is_job_template());
640            }
641        }
642    }
643
644    #[test]
645    fn test_from_str_roundtrip() {
646        for v in ALL_VERSIONS {
647            let s = v.as_str();
648            let parsed: Result<TemplateSpecificationVersion, _> = s.parse();
649            assert_eq!(parsed, Ok(*v));
650        }
651        assert!("unknown".parse::<TemplateSpecificationVersion>().is_err());
652    }
653
654    #[test]
655    fn test_revision() {
656        for v in ALL_VERSIONS {
657            assert_eq!(v.revision(), SpecificationRevision::V2023_09);
658        }
659    }
660
661    // ── JobParameterType tests ──
662
663    const ALL_JOB_PARAM_TYPES: &[JobParameterType] = &[
664        JobParameterType::String,
665        JobParameterType::Int,
666        JobParameterType::Float,
667        JobParameterType::Path,
668        JobParameterType::Bool,
669        JobParameterType::RangeExpr,
670        JobParameterType::ListString,
671        JobParameterType::ListInt,
672        JobParameterType::ListFloat,
673        JobParameterType::ListPath,
674        JobParameterType::ListBool,
675        JobParameterType::ListListInt,
676    ];
677
678    #[test]
679    fn test_job_param_type_roundtrip() {
680        for &t in ALL_JOB_PARAM_TYPES {
681            let s = t.as_spec_str();
682            let parsed = JobParameterType::from_spec_str(s).unwrap();
683            assert_eq!(parsed, t, "round-trip failed for {s}");
684        }
685    }
686
687    #[test]
688    fn test_job_param_type_case_insensitive() {
689        assert_eq!(
690            JobParameterType::from_spec_str("string"),
691            Some(JobParameterType::String)
692        );
693        assert_eq!(
694            JobParameterType::from_spec_str("Int"),
695            Some(JobParameterType::Int)
696        );
697        assert_eq!(
698            JobParameterType::from_spec_str("list[int]"),
699            Some(JobParameterType::ListInt)
700        );
701        assert_eq!(
702            JobParameterType::from_spec_str("List[List[Int]]"),
703            Some(JobParameterType::ListListInt)
704        );
705        assert_eq!(
706            JobParameterType::from_spec_str("range_expr"),
707            Some(JobParameterType::RangeExpr)
708        );
709    }
710
711    #[test]
712    fn test_job_param_type_unknown() {
713        assert_eq!(JobParameterType::from_spec_str("UNKNOWN"), None);
714        assert_eq!(JobParameterType::from_spec_str(""), None);
715        assert_eq!(JobParameterType::from_spec_str("LIST[UNKNOWN]"), None);
716    }
717
718    #[test]
719    fn test_job_param_type_expr_type() {
720        assert_eq!(JobParameterType::String.expr_type(), ExprType::STRING);
721        assert_eq!(JobParameterType::Path.expr_type(), ExprType::PATH);
722        assert_eq!(
723            JobParameterType::ListInt.expr_type(),
724            ExprType::list(ExprType::INT)
725        );
726        assert_eq!(
727            JobParameterType::ListListInt.expr_type(),
728            ExprType::list(ExprType::list(ExprType::INT))
729        );
730    }
731
732    #[test]
733    fn test_job_param_type_display() {
734        assert_eq!(format!("{}", JobParameterType::String), "STRING");
735        assert_eq!(format!("{}", JobParameterType::ListPath), "LIST[PATH]");
736    }
737
738    // ── TaskParameterType tests ──
739
740    const ALL_TASK_PARAM_TYPES: &[TaskParameterType] = &[
741        TaskParameterType::Int,
742        TaskParameterType::Float,
743        TaskParameterType::String,
744        TaskParameterType::Path,
745        TaskParameterType::ChunkInt,
746    ];
747
748    #[test]
749    fn test_task_param_type_roundtrip() {
750        for &t in ALL_TASK_PARAM_TYPES {
751            let s = t.as_spec_str();
752            let parsed = TaskParameterType::from_spec_str(s).unwrap();
753            assert_eq!(parsed, t, "round-trip failed for {s}");
754        }
755    }
756
757    #[test]
758    fn test_task_param_type_unknown() {
759        assert_eq!(TaskParameterType::from_spec_str("UNKNOWN"), None);
760        assert_eq!(TaskParameterType::from_spec_str("BOOL"), None);
761    }
762
763    #[test]
764    fn test_task_param_type_expr_type() {
765        assert_eq!(TaskParameterType::String.expr_type(), ExprType::STRING);
766        assert_eq!(TaskParameterType::Path.expr_type(), ExprType::PATH);
767        assert_eq!(
768            TaskParameterType::ChunkInt.expr_type(),
769            ExprType::RANGE_EXPR
770        );
771    }
772
773    #[test]
774    fn test_task_param_type_display() {
775        assert_eq!(format!("{}", TaskParameterType::ChunkInt), "CHUNK[INT]");
776        assert_eq!(format!("{}", TaskParameterType::Int), "INT");
777    }
778
779    #[test]
780    fn test_model_extension_serializes_as_canonical_string() {
781        // ModelExtension must round-trip through the canonical
782        // UPPER_SNAKE_CASE name rather than the Rust variant name, so
783        // serialized Jobs match the form consumed by tools and the
784        // Python implementation.
785        assert_eq!(
786            serde_json::to_string(&ModelExtension::Expr).unwrap(),
787            "\"EXPR\""
788        );
789        assert_eq!(
790            serde_json::to_string(&ModelExtension::TaskChunking).unwrap(),
791            "\"TASK_CHUNKING\""
792        );
793        assert_eq!(
794            serde_json::to_string(&ModelExtension::RedactedEnvVars).unwrap(),
795            "\"REDACTED_ENV_VARS\""
796        );
797        assert_eq!(
798            serde_json::to_string(&ModelExtension::FeatureBundle1).unwrap(),
799            "\"FEATURE_BUNDLE_1\""
800        );
801        let v = vec![ModelExtension::Expr, ModelExtension::TaskChunking];
802        assert_eq!(
803            serde_json::to_string(&v).unwrap(),
804            "[\"EXPR\",\"TASK_CHUNKING\"]"
805        );
806    }
807}