Skip to main content

openjd_model/template/
parameters.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//! Job parameter definitions per spec §2.
6
7use super::constrained_strings::{Description, Identifier};
8use crate::types::{DataFlow, ObjectType};
9use serde::Deserialize;
10
11/// Wrapper for `Option<Vec<T>>` that rejects explicit YAML `null`.
12///
13/// With `#[serde(default)]`, absent fields get `Default::default()` (inner None)
14/// without calling the deserializer. Present-but-null fields DO call the
15/// deserializer, where we reject them. This lets us distinguish:
16/// - absent → `NullableVec(None)` via Default
17/// - `null` → deserialization error
18/// - `[]` → `NullableVec(Some([]))`
19/// - `[1,2]` → `NullableVec(Some([1,2]))`
20#[derive(Debug, Clone)]
21pub struct NullableVec<T>(pub Option<Vec<T>>);
22
23impl<T> Default for NullableVec<T> {
24    fn default() -> Self {
25        NullableVec(None)
26    }
27}
28
29impl<T> NullableVec<T> {
30    pub fn as_ref(&self) -> Option<&Vec<T>> {
31        self.0.as_ref()
32    }
33    pub fn is_some(&self) -> bool {
34        self.0.is_some()
35    }
36    pub fn is_none(&self) -> bool {
37        self.0.is_none()
38    }
39}
40
41impl<'de, T: serde::de::DeserializeOwned> Deserialize<'de> for NullableVec<T> {
42    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
43        match Option::<Vec<T>>::deserialize(deserializer)? {
44            None => Err(serde::de::Error::custom(
45                "null is not allowed for this field",
46            )),
47            Some(vec) => Ok(NullableVec(Some(vec))),
48        }
49    }
50}
51
52/// §2 JobParameterDefinition — discriminated union on `type` field.
53///
54/// With the EXPR extension, type names are case-insensitive and additional
55/// types are available (BOOL, RANGE_EXPR, `LIST[*]`).
56#[derive(Debug, Clone)]
57#[allow(non_camel_case_types)]
58pub enum JobParameterDefinition {
59    STRING(JobStringParameterDefinition),
60    INT(JobIntParameterDefinition),
61    FLOAT(JobFloatParameterDefinition),
62    PATH(JobPathParameterDefinition),
63    // EXPR extension types
64    BOOL(super::expr_parameters::JobBoolParameterDefinition),
65    RANGE_EXPR(super::expr_parameters::JobRangeExprParameterDefinition),
66    LIST_STRING(super::expr_parameters::JobListStringParameterDefinition),
67    LIST_PATH(super::expr_parameters::JobListPathParameterDefinition),
68    LIST_INT(super::expr_parameters::JobListIntParameterDefinition),
69    LIST_FLOAT(super::expr_parameters::JobListFloatParameterDefinition),
70    LIST_BOOL(super::expr_parameters::JobListBoolParameterDefinition),
71    LIST_LIST_INT(super::expr_parameters::JobListListIntParameterDefinition),
72}
73
74/// Remove the `type` field from a JSON object before deserializing into a struct.
75fn strip_type_field(mut value: serde_json::Value) -> serde_json::Value {
76    if let Some(obj) = value.as_object_mut() {
77        obj.remove("type");
78    }
79    value
80}
81
82impl<'de> serde::Deserialize<'de> for JobParameterDefinition {
83    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
84        let value = serde_json::Value::deserialize(deserializer)?;
85        let type_str = value
86            .get("type")
87            .and_then(|v| v.as_str())
88            .ok_or_else(|| {
89                serde::de::Error::custom("missing 'type' field in parameter definition")
90            })?
91            .to_string();
92
93        let normalized = type_str.to_uppercase();
94        let stripped = strip_type_field(value);
95
96        match normalized.as_str() {
97            "STRING" => serde_json::from_value(stripped)
98                .map(Self::STRING)
99                .map_err(serde::de::Error::custom),
100            "INT" => serde_json::from_value(stripped)
101                .map(Self::INT)
102                .map_err(serde::de::Error::custom),
103            "FLOAT" => serde_json::from_value(stripped)
104                .map(Self::FLOAT)
105                .map_err(serde::de::Error::custom),
106            "PATH" => serde_json::from_value(stripped)
107                .map(Self::PATH)
108                .map_err(serde::de::Error::custom),
109            "BOOL" => serde_json::from_value(stripped)
110                .map(Self::BOOL)
111                .map_err(serde::de::Error::custom),
112            "RANGE_EXPR" => serde_json::from_value(stripped)
113                .map(Self::RANGE_EXPR)
114                .map_err(serde::de::Error::custom),
115            "LIST[STRING]" => serde_json::from_value(stripped)
116                .map(Self::LIST_STRING)
117                .map_err(serde::de::Error::custom),
118            "LIST[PATH]" => serde_json::from_value(stripped)
119                .map(Self::LIST_PATH)
120                .map_err(serde::de::Error::custom),
121            "LIST[INT]" => serde_json::from_value(stripped)
122                .map(Self::LIST_INT)
123                .map_err(serde::de::Error::custom),
124            "LIST[FLOAT]" => serde_json::from_value(stripped)
125                .map(Self::LIST_FLOAT)
126                .map_err(serde::de::Error::custom),
127            "LIST[BOOL]" => serde_json::from_value(stripped)
128                .map(Self::LIST_BOOL)
129                .map_err(serde::de::Error::custom),
130            "LIST[LIST[INT]]" => serde_json::from_value(stripped)
131                .map(Self::LIST_LIST_INT)
132                .map_err(serde::de::Error::custom),
133            _ => Err(serde::de::Error::custom(format!(
134                "unknown parameter type: '{type_str}'"
135            ))),
136        }
137    }
138}
139
140impl JobParameterDefinition {
141    pub fn job_param_type(&self) -> crate::types::JobParameterType {
142        use crate::types::JobParameterType;
143        match self {
144            Self::STRING(_) => JobParameterType::String,
145            Self::INT(_) => JobParameterType::Int,
146            Self::FLOAT(_) => JobParameterType::Float,
147            Self::PATH(_) => JobParameterType::Path,
148            Self::BOOL(_) => JobParameterType::Bool,
149            Self::RANGE_EXPR(_) => JobParameterType::RangeExpr,
150            Self::LIST_STRING(_) => JobParameterType::ListString,
151            Self::LIST_PATH(_) => JobParameterType::ListPath,
152            Self::LIST_INT(_) => JobParameterType::ListInt,
153            Self::LIST_FLOAT(_) => JobParameterType::ListFloat,
154            Self::LIST_BOOL(_) => JobParameterType::ListBool,
155            Self::LIST_LIST_INT(_) => JobParameterType::ListListInt,
156        }
157    }
158
159    pub fn name(&self) -> &str {
160        match self {
161            Self::STRING(p) => p.name.as_str(),
162            Self::INT(p) => p.name.as_str(),
163            Self::FLOAT(p) => p.name.as_str(),
164            Self::PATH(p) => p.name.as_str(),
165            Self::BOOL(p) => p.name.as_str(),
166            Self::RANGE_EXPR(p) => p.name.as_str(),
167            Self::LIST_STRING(p) => p.name.as_str(),
168            Self::LIST_PATH(p) => p.name.as_str(),
169            Self::LIST_INT(p) => p.name.as_str(),
170            Self::LIST_FLOAT(p) => p.name.as_str(),
171            Self::LIST_BOOL(p) => p.name.as_str(),
172            Self::LIST_LIST_INT(p) => p.name.as_str(),
173        }
174    }
175
176    pub fn description(&self) -> Option<&str> {
177        match self {
178            Self::STRING(p) => p.description.as_ref().map(|d| d.0.as_str()),
179            Self::INT(p) => p.description.as_ref().map(|d| d.0.as_str()),
180            Self::FLOAT(p) => p.description.as_ref().map(|d| d.0.as_str()),
181            Self::PATH(p) => p.description.as_ref().map(|d| d.0.as_str()),
182            Self::BOOL(p) => p.description.as_ref().map(|d| d.0.as_str()),
183            Self::RANGE_EXPR(p) => p.description.as_ref().map(|d| d.0.as_str()),
184            Self::LIST_STRING(p) => p.description.as_ref().map(|d| d.0.as_str()),
185            Self::LIST_PATH(p) => p.description.as_ref().map(|d| d.0.as_str()),
186            Self::LIST_INT(p) => p.description.as_ref().map(|d| d.0.as_str()),
187            Self::LIST_FLOAT(p) => p.description.as_ref().map(|d| d.0.as_str()),
188            Self::LIST_BOOL(p) => p.description.as_ref().map(|d| d.0.as_str()),
189            Self::LIST_LIST_INT(p) => p.description.as_ref().map(|d| d.0.as_str()),
190        }
191    }
192
193    pub fn type_name(&self) -> &str {
194        match self {
195            Self::STRING(_) => "STRING",
196            Self::INT(_) => "INT",
197            Self::FLOAT(_) => "FLOAT",
198            Self::PATH(_) => "PATH",
199            Self::BOOL(_) => "BOOL",
200            Self::RANGE_EXPR(_) => "RANGE_EXPR",
201            Self::LIST_STRING(_) => "LIST[STRING]",
202            Self::LIST_PATH(_) => "LIST[PATH]",
203            Self::LIST_INT(_) => "LIST[INT]",
204            Self::LIST_FLOAT(_) => "LIST[FLOAT]",
205            Self::LIST_BOOL(_) => "LIST[BOOL]",
206            Self::LIST_LIST_INT(_) => "LIST[LIST[INT]]",
207        }
208    }
209
210    pub fn path_properties(&self) -> (Option<ObjectType>, Option<DataFlow>) {
211        match self {
212            Self::PATH(p) => (p.object_type, p.data_flow),
213            Self::LIST_PATH(p) => (p.object_type, p.data_flow),
214            _ => (None, None),
215        }
216    }
217
218    pub fn default_value(&self) -> Option<String> {
219        match self {
220            Self::STRING(p) => p.default.clone(),
221            Self::INT(p) => p.default.as_ref().map(|v| v.to_string()),
222            Self::FLOAT(p) => p
223                .default
224                .as_ref()
225                .map(|v| v.1.clone().unwrap_or_else(|| v.0.to_string())),
226            Self::PATH(p) => p.default.clone(),
227            Self::BOOL(p) => p.default.as_ref().map(|v| v.0.to_string()),
228            Self::RANGE_EXPR(p) => p.default.clone(),
229            Self::LIST_STRING(p) => p
230                .default
231                .as_ref()
232                .map(|v| serde_json::to_string(v).unwrap_or_default()),
233            Self::LIST_PATH(p) => p
234                .default
235                .as_ref()
236                .map(|v| serde_json::to_string(v).unwrap_or_default()),
237            Self::LIST_INT(p) => p.default.as_ref().map(|v| {
238                let ints: Vec<i64> = v.iter().map(|i| i.0).collect();
239                serde_json::to_string(&ints).unwrap_or_default()
240            }),
241            Self::LIST_FLOAT(p) => p.default.as_ref().map(|v| {
242                let floats: Vec<f64> = v.iter().map(|f| f.0).collect();
243                serde_json::to_string(&floats).unwrap_or_default()
244            }),
245            Self::LIST_BOOL(p) => p.default.as_ref().map(|v| {
246                let bools: Vec<bool> = v.iter().map(|b| b.0).collect();
247                serde_json::to_string(&bools).unwrap_or_default()
248            }),
249            Self::LIST_LIST_INT(p) => p.default.as_ref().map(|v| {
250                let lists: Vec<Vec<i64>> = v
251                    .iter()
252                    .map(|inner| inner.iter().map(|i| i.0).collect())
253                    .collect();
254                serde_json::to_string(&lists).unwrap_or_default()
255            }),
256        }
257    }
258
259    pub fn min_value_i64(&self) -> Option<i64> {
260        match self {
261            Self::INT(p) => p.min_value.as_ref().map(|v| v.0),
262            _ => None,
263        }
264    }
265
266    pub fn max_value_i64(&self) -> Option<i64> {
267        match self {
268            Self::INT(p) => p.max_value.as_ref().map(|v| v.0),
269            _ => None,
270        }
271    }
272
273    pub fn allowed_values_i64(&self) -> Option<Vec<i64>> {
274        match self {
275            Self::INT(p) => p
276                .allowed_values
277                .as_ref()
278                .map(|v| v.iter().map(|i| i.0).collect()),
279            _ => None,
280        }
281    }
282
283    pub fn allowed_values_f64(&self) -> Option<Vec<f64>> {
284        match self {
285            Self::FLOAT(p) => p
286                .allowed_values
287                .as_ref()
288                .map(|v| v.iter().map(|f| f.0).collect()),
289            _ => None,
290        }
291    }
292
293    pub fn allowed_values_strings(&self) -> Option<Vec<String>> {
294        match self {
295            Self::STRING(p) => p.allowed_values.clone(),
296            Self::PATH(p) => p.allowed_values.clone(),
297            _ => None,
298        }
299    }
300
301    pub fn min_length(&self) -> Option<usize> {
302        match self {
303            Self::STRING(p) => p.min_length,
304            Self::PATH(p) => p.min_length,
305            Self::RANGE_EXPR(p) => p.min_length,
306            Self::LIST_STRING(p) => p.min_length,
307            Self::LIST_PATH(p) => p.min_length,
308            Self::LIST_INT(p) => p.min_length,
309            Self::LIST_FLOAT(p) => p.min_length,
310            Self::LIST_BOOL(p) => p.min_length,
311            Self::LIST_LIST_INT(p) => p.min_length,
312            _ => None,
313        }
314    }
315
316    pub fn max_length(&self) -> Option<usize> {
317        match self {
318            Self::STRING(p) => p.max_length,
319            Self::PATH(p) => p.max_length,
320            Self::RANGE_EXPR(p) => p.max_length,
321            Self::LIST_STRING(p) => p.max_length,
322            Self::LIST_PATH(p) => p.max_length,
323            Self::LIST_INT(p) => p.max_length,
324            Self::LIST_FLOAT(p) => p.max_length,
325            Self::LIST_BOOL(p) => p.max_length,
326            Self::LIST_LIST_INT(p) => p.max_length,
327            _ => None,
328        }
329    }
330
331    pub fn min_value_f64(&self) -> Option<f64> {
332        match self {
333            Self::FLOAT(p) => p.min_value.as_ref().map(|v| v.0),
334            _ => None,
335        }
336    }
337
338    pub fn max_value_f64(&self) -> Option<f64> {
339        match self {
340            Self::FLOAT(p) => p.max_value.as_ref().map(|v| v.0),
341            _ => None,
342        }
343    }
344
345    // --- List per-item constraint accessors ---
346
347    /// `item.minValue` for `LIST[INT]`; the `item.item.minValue` path for `LIST[LIST[INT]]` is separate.
348    pub fn item_min_value_i64(&self) -> Option<i64> {
349        match self {
350            Self::LIST_INT(p) => p
351                .item
352                .as_ref()
353                .and_then(|i| i.min_value.as_ref().map(|v| v.0)),
354            _ => None,
355        }
356    }
357
358    /// `item.maxValue` for `LIST[INT]`.
359    pub fn item_max_value_i64(&self) -> Option<i64> {
360        match self {
361            Self::LIST_INT(p) => p
362                .item
363                .as_ref()
364                .and_then(|i| i.max_value.as_ref().map(|v| v.0)),
365            _ => None,
366        }
367    }
368
369    /// `item.allowedValues` for `LIST[INT]` as `Vec<i64>`.
370    pub fn item_allowed_values_i64(&self) -> Option<Vec<i64>> {
371        match self {
372            Self::LIST_INT(p) => p.item.as_ref().and_then(|i| {
373                i.allowed_values
374                    .as_ref()
375                    .map(|v| v.iter().map(|x| x.0).collect())
376            }),
377            _ => None,
378        }
379    }
380
381    /// `item.minValue` for `LIST[FLOAT]`.
382    pub fn item_min_value_f64(&self) -> Option<f64> {
383        match self {
384            Self::LIST_FLOAT(p) => p
385                .item
386                .as_ref()
387                .and_then(|i| i.min_value.as_ref().map(|v| v.0)),
388            _ => None,
389        }
390    }
391
392    /// `item.maxValue` for `LIST[FLOAT]`.
393    pub fn item_max_value_f64(&self) -> Option<f64> {
394        match self {
395            Self::LIST_FLOAT(p) => p
396                .item
397                .as_ref()
398                .and_then(|i| i.max_value.as_ref().map(|v| v.0)),
399            _ => None,
400        }
401    }
402
403    /// `item.allowedValues` for `LIST[FLOAT]` as `Vec<f64>`.
404    pub fn item_allowed_values_f64(&self) -> Option<Vec<f64>> {
405        match self {
406            Self::LIST_FLOAT(p) => p.item.as_ref().and_then(|i| {
407                i.allowed_values
408                    .as_ref()
409                    .map(|v| v.iter().map(|x| x.0).collect())
410            }),
411            _ => None,
412        }
413    }
414
415    /// `item.minLength` for `LIST[STRING]`/`LIST[PATH]` (char count) or `LIST[LIST[INT]]` (inner list count).
416    pub fn item_min_length(&self) -> Option<usize> {
417        match self {
418            Self::LIST_STRING(p) => p.item.as_ref().and_then(|i| i.min_length),
419            Self::LIST_PATH(p) => p.item.as_ref().and_then(|i| i.min_length),
420            Self::LIST_LIST_INT(p) => p.item.as_ref().and_then(|i| i.min_length),
421            _ => None,
422        }
423    }
424
425    /// `item.maxLength` for `LIST[STRING]`/`LIST[PATH]` (char count) or `LIST[LIST[INT]]` (inner list count).
426    pub fn item_max_length(&self) -> Option<usize> {
427        match self {
428            Self::LIST_STRING(p) => p.item.as_ref().and_then(|i| i.max_length),
429            Self::LIST_PATH(p) => p.item.as_ref().and_then(|i| i.max_length),
430            Self::LIST_LIST_INT(p) => p.item.as_ref().and_then(|i| i.max_length),
431            _ => None,
432        }
433    }
434
435    /// `item.allowedValues` for `LIST[STRING]`/`LIST[PATH]` as `Vec<String>`.
436    pub fn item_allowed_values_strings(&self) -> Option<Vec<String>> {
437        match self {
438            Self::LIST_STRING(p) => p.item.as_ref().and_then(|i| i.allowed_values.clone()),
439            Self::LIST_PATH(p) => p.item.as_ref().and_then(|i| i.allowed_values.clone()),
440            _ => None,
441        }
442    }
443
444    /// `item.item.minValue` for `LIST[LIST[INT]]`.
445    pub fn item_item_min_value_i64(&self) -> Option<i64> {
446        match self {
447            Self::LIST_LIST_INT(p) => p
448                .item
449                .as_ref()
450                .and_then(|i| i.item.as_ref())
451                .and_then(|ii| ii.min_value.as_ref().map(|v| v.0)),
452            _ => None,
453        }
454    }
455
456    /// `item.item.maxValue` for `LIST[LIST[INT]]`.
457    pub fn item_item_max_value_i64(&self) -> Option<i64> {
458        match self {
459            Self::LIST_LIST_INT(p) => p
460                .item
461                .as_ref()
462                .and_then(|i| i.item.as_ref())
463                .and_then(|ii| ii.max_value.as_ref().map(|v| v.0)),
464            _ => None,
465        }
466    }
467
468    /// `item.item.allowedValues` for `LIST[LIST[INT]]` as `Vec<i64>`.
469    pub fn item_item_allowed_values_i64(&self) -> Option<Vec<i64>> {
470        match self {
471            Self::LIST_LIST_INT(p) => {
472                p.item
473                    .as_ref()
474                    .and_then(|i| i.item.as_ref())
475                    .and_then(|ii| {
476                        ii.allowed_values
477                            .as_ref()
478                            .map(|v| v.iter().map(|x| x.0).collect())
479                    })
480            }
481            _ => None,
482        }
483    }
484
485    pub fn check_constraints(&self, value: &openjd_expr::ExprValue) -> Result<(), String> {
486        // Extract string representation for base type constraint checking
487        let s;
488        let str_val = match value {
489            openjd_expr::ExprValue::String(v) => v.as_str(),
490            openjd_expr::ExprValue::Int(v) => {
491                s = v.to_string();
492                &s
493            }
494            openjd_expr::ExprValue::Float(v) => {
495                s = v.to_string();
496                &s
497            }
498            openjd_expr::ExprValue::Bool(v) => {
499                s = v.to_string();
500                &s
501            }
502            openjd_expr::ExprValue::Path { value: v, .. } => v.as_str(),
503            _ => "", // List types — base constraint checking doesn't apply
504        };
505        match self {
506            Self::STRING(p) => p.check_constraints(str_val),
507            Self::INT(p) => p.check_constraints(str_val),
508            Self::FLOAT(p) => p.check_constraints(str_val),
509            Self::PATH(p) => p.check_constraints(str_val),
510            Self::BOOL(p) => p.check_value_constraints(value),
511            Self::RANGE_EXPR(p) => p.check_value_constraints(value),
512            Self::LIST_STRING(p) => p.check_value_constraints(value),
513            Self::LIST_PATH(p) => p.check_value_constraints(value),
514            Self::LIST_INT(p) => p.check_value_constraints(value),
515            Self::LIST_FLOAT(p) => p.check_value_constraints(value),
516            Self::LIST_BOOL(p) => p.check_value_constraints(value),
517            Self::LIST_LIST_INT(p) => p.check_value_constraints(value),
518        }
519    }
520
521    pub fn validate_definition(
522        &self,
523        limits: &super::validate_v2023_09::EffectiveLimits,
524    ) -> Result<(), Vec<String>> {
525        match self {
526            Self::STRING(p) => p.validate_definition(limits),
527            Self::INT(p) => p.validate_definition(),
528            Self::FLOAT(p) => p.validate_definition(),
529            Self::PATH(p) => p.validate_definition(limits),
530            Self::BOOL(p) => p.validate_definition(),
531            Self::RANGE_EXPR(p) => p.validate_definition(),
532            Self::LIST_STRING(p) => p.validate_definition(),
533            Self::LIST_PATH(p) => p.validate_definition(),
534            Self::LIST_INT(p) => p.validate_definition(),
535            Self::LIST_FLOAT(p) => p.validate_definition(),
536            Self::LIST_BOOL(p) => p.validate_definition(),
537            Self::LIST_LIST_INT(p) => p.validate_definition(),
538        }
539    }
540}
541
542/// User interface definition for STRING parameters.
543#[derive(Debug, Clone, Deserialize)]
544#[serde(rename_all = "camelCase", deny_unknown_fields)]
545pub struct StringUserInterface {
546    pub control: Option<String>,
547    pub label: Option<String>,
548    pub group_label: Option<String>,
549}
550
551/// User interface definition for INT parameters.
552#[derive(Debug, Clone, Deserialize)]
553#[serde(rename_all = "camelCase", deny_unknown_fields)]
554pub struct IntUserInterface {
555    pub control: Option<String>,
556    pub label: Option<String>,
557    pub group_label: Option<String>,
558    pub single_step_delta: Option<FlexInt>,
559}
560
561/// User interface definition for FLOAT parameters.
562#[derive(Debug, Clone, Deserialize)]
563#[serde(rename_all = "camelCase", deny_unknown_fields)]
564pub struct FloatUserInterface {
565    pub control: Option<String>,
566    pub label: Option<String>,
567    pub group_label: Option<String>,
568    pub decimals: Option<FlexInt>,
569    pub single_step_delta: Option<FlexFloat>,
570}
571
572/// User interface definition for PATH parameters.
573#[derive(Debug, Clone, Deserialize)]
574#[serde(rename_all = "camelCase", deny_unknown_fields)]
575pub struct PathUserInterface {
576    pub control: Option<String>,
577    pub label: Option<String>,
578    pub group_label: Option<String>,
579    pub file_filters: Option<Vec<FileFilter>>,
580    pub file_filter_default: Option<FileFilter>,
581}
582
583/// §2.7 JobPathParameterFileFilter
584#[derive(Debug, Clone, Deserialize)]
585#[serde(rename_all = "camelCase", deny_unknown_fields)]
586pub struct FileFilter {
587    pub label: String,
588    pub patterns: Vec<String>,
589}
590
591pub(crate) fn validate_ui_label(
592    label: &Option<String>,
593    field_name: &str,
594    param_name: &str,
595) -> Vec<String> {
596    let mut errors = Vec::new();
597    if let Some(l) = label {
598        if l.is_empty() {
599            errors.push(format!(
600                "Parameter '{param_name}': {field_name} must not be empty."
601            ));
602        }
603        if l.chars().count() > 64 {
604            errors.push(format!(
605                "Parameter '{param_name}': {field_name} exceeds 64 characters."
606            ));
607        }
608        if l.chars().any(|c| c.is_control()) {
609            errors.push(format!(
610                "Parameter '{param_name}': {field_name} contains control characters."
611            ));
612        }
613    }
614    errors
615}
616
617/// §2.1 JobStringParameterDefinition
618#[derive(Debug, Clone, Deserialize)]
619#[serde(rename_all = "camelCase", deny_unknown_fields)]
620pub struct JobStringParameterDefinition {
621    pub name: Identifier,
622    pub description: Option<Description>,
623    pub default: Option<String>,
624    pub allowed_values: Option<Vec<String>>,
625    pub min_length: Option<usize>,
626    pub max_length: Option<usize>,
627    pub user_interface: Option<StringUserInterface>,
628}
629
630impl JobStringParameterDefinition {
631    pub fn check_constraints(&self, value: &str) -> Result<(), String> {
632        let char_len = value.chars().count();
633        if let Some(min) = self.min_length {
634            if char_len < min {
635                return Err(format!(
636                    "Parameter '{}': value length {} is less than minimum {min}",
637                    self.name, char_len
638                ));
639            }
640        }
641        if let Some(max) = self.max_length {
642            if char_len > max {
643                return Err(format!(
644                    "Parameter '{}': value length {} exceeds maximum {max}",
645                    self.name, char_len
646                ));
647            }
648        }
649        if let Some(allowed) = self.allowed_values.as_ref() {
650            if !allowed.iter().any(|a| a == value) {
651                return Err(format!(
652                    "Parameter '{}': value '{value}' is not in allowed values",
653                    self.name
654                ));
655            }
656        }
657        Ok(())
658    }
659
660    pub fn validate_definition(
661        &self,
662        limits: &super::validate_v2023_09::EffectiveLimits,
663    ) -> Result<(), Vec<String>> {
664        let mut errors = Vec::new();
665
666        // §2.5: allowed values max length
667        if let Some(allowed) = self.allowed_values.as_ref() {
668            if allowed.is_empty() {
669                errors.push(format!(
670                    "Parameter '{}': allowedValues must not be empty.",
671                    self.name
672                ));
673            }
674            for (i, v) in allowed.iter().enumerate() {
675                let vlen = v.chars().count();
676                if vlen > limits.max_job_param_string_len {
677                    errors.push(format!(
678                        "Parameter '{}': allowedValues[{i}] exceeds {} characters.",
679                        self.name, limits.max_job_param_string_len
680                    ));
681                }
682                if let Some(min) = self.min_length {
683                    if vlen < min {
684                        errors.push(format!("Parameter '{}': allowedValues[{i}] length {vlen} is less than minLength {min}.", self.name));
685                    }
686                }
687                if let Some(max) = self.max_length {
688                    if vlen > max {
689                        errors.push(format!(
690                            "Parameter '{}': allowedValues[{i}] length {vlen} exceeds maxLength {max}.",
691                            self.name,
692                        ));
693                    }
694                }
695            }
696        }
697
698        // min/max length consistency
699        if let (Some(min), Some(max)) = (self.min_length, self.max_length) {
700            if min > max {
701                errors.push(format!(
702                    "Parameter '{}': minLength ({min}) > maxLength ({max}).",
703                    self.name
704                ));
705            }
706        }
707        if let Some(max) = self.max_length {
708            if max == 0 {
709                errors.push(format!("Parameter '{}': maxLength must be > 0.", self.name));
710            }
711        }
712
713        // Default must satisfy constraints
714        if let Some(default) = &self.default {
715            let dlen = default.chars().count();
716            if dlen > limits.max_job_param_string_len {
717                errors.push(format!(
718                    "Parameter '{}': default exceeds {} characters.",
719                    self.name, limits.max_job_param_string_len
720                ));
721            }
722            if let Some(min) = self.min_length {
723                if dlen < min {
724                    errors.push(format!(
725                        "Parameter '{}': default length {dlen} is less than minLength {min}.",
726                        self.name,
727                    ));
728                }
729            }
730            if let Some(max) = self.max_length {
731                if dlen > max {
732                    errors.push(format!(
733                        "Parameter '{}': default length {dlen} exceeds maxLength {max}.",
734                        self.name,
735                    ));
736                }
737            }
738            if let Some(allowed) = self.allowed_values.as_ref() {
739                if !allowed.contains(default) {
740                    errors.push(format!(
741                        "Parameter '{}': default '{}' is not in allowedValues.",
742                        self.name, default
743                    ));
744                }
745            }
746        }
747
748        // UI validation
749        if let Some(ui) = &self.user_interface {
750            errors.extend(validate_ui_label(&ui.label, "label", self.name.as_str()));
751            errors.extend(validate_ui_label(
752                &ui.group_label,
753                "groupLabel",
754                self.name.as_str(),
755            ));
756
757            if let Some(control) = &ui.control {
758                match control.as_str() {
759                    "LINE_EDIT" | "MULTILINE_EDIT" => {
760                        if self.allowed_values.is_some() {
761                            errors.push(format!("Parameter '{}': control '{control}' cannot be used with allowedValues.", self.name));
762                        }
763                    }
764                    "DROPDOWN_LIST" => {
765                        if self.allowed_values.is_none() {
766                            errors.push(format!(
767                                "Parameter '{}': DROPDOWN_LIST requires allowedValues.",
768                                self.name
769                            ));
770                        }
771                    }
772                    "CHECK_BOX" => {
773                        if let Some(allowed) = self.allowed_values.as_ref() {
774                            if allowed.len() != 2 {
775                                errors.push(format!(
776                                    "Parameter '{}': CHECK_BOX requires exactly 2 allowedValues.",
777                                    self.name
778                                ));
779                            } else {
780                                let pair: Vec<String> =
781                                    allowed.iter().map(|s| s.to_lowercase()).collect();
782                                let valid_pairs = [
783                                    vec!["true", "false"],
784                                    vec!["false", "true"],
785                                    vec!["yes", "no"],
786                                    vec!["no", "yes"],
787                                    vec!["on", "off"],
788                                    vec!["off", "on"],
789                                    vec!["1", "0"],
790                                    vec!["0", "1"],
791                                ];
792                                if !valid_pairs.iter().any(|vp| vp == &pair) {
793                                    errors.push(format!("Parameter '{}': CHECK_BOX allowedValues must be a valid boolean pair.", self.name));
794                                }
795                            }
796                        } else {
797                            errors.push(format!(
798                                "Parameter '{}': CHECK_BOX requires allowedValues.",
799                                self.name
800                            ));
801                        }
802                    }
803                    "HIDDEN" => {}
804                    _ => {
805                        errors.push(format!(
806                            "Parameter '{}': unknown control '{control}'.",
807                            self.name
808                        ));
809                    }
810                }
811            }
812        }
813
814        if errors.is_empty() {
815            Ok(())
816        } else {
817            Err(errors)
818        }
819    }
820}
821
822/// An `i64` that deserializes from YAML integers, integer-valued floats (e.g. `42.0`), or
823/// numeric strings (e.g. `"42"`). Rejects booleans, nulls, and non-integer floats.
824#[derive(Debug, Clone)]
825pub struct FlexInt(pub i64);
826
827impl<'de> Deserialize<'de> for FlexInt {
828    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
829        let val = serde_json::Value::deserialize(deserializer)?;
830        match &val {
831            serde_json::Value::Number(n) => {
832                if let Some(i) = n.as_i64() {
833                    Ok(FlexInt(i))
834                } else if let Some(f) = n.as_f64() {
835                    if f.fract() == 0.0 {
836                        Ok(FlexInt(f as i64))
837                    } else {
838                        Err(serde::de::Error::custom(format!(
839                            "Expected integer, got float: {f}"
840                        )))
841                    }
842                } else {
843                    Err(serde::de::Error::custom("Invalid number"))
844                }
845            }
846            serde_json::Value::String(s) => s
847                .trim()
848                .parse::<i64>()
849                .map(FlexInt)
850                .map_err(|_| serde::de::Error::custom(format!("Cannot parse '{s}' as integer"))),
851            serde_json::Value::Bool(_) => {
852                Err(serde::de::Error::custom("Expected integer, got boolean"))
853            }
854            serde_json::Value::Null => Err(serde::de::Error::custom("Expected integer, got null")),
855            _ => Err(serde::de::Error::custom("Expected integer or string")),
856        }
857    }
858}
859
860impl std::fmt::Display for FlexInt {
861    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
862        write!(f, "{}", self.0)
863    }
864}
865
866/// An `f64` that deserializes from YAML numbers or numeric strings (e.g. `"3.14"`).
867/// Preserves the original string representation when parsed from a string, which is needed
868/// for round-trip fidelity in constraint checking. Rejects NaN, Infinity, booleans, and nulls.
869#[derive(Debug, Clone)]
870pub struct FlexFloat(pub f64, pub Option<String>);
871
872/// Reject NaN and Infinity float values.
873pub(crate) fn reject_nan_inf(f: f64) -> Result<(), String> {
874    if f.is_nan() {
875        Err("NaN is not a valid float value".to_string())
876    } else if f.is_infinite() {
877        Err("Infinity is not a valid float value".to_string())
878    } else {
879        Ok(())
880    }
881}
882
883impl<'de> Deserialize<'de> for FlexFloat {
884    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
885        let val = serde_json::Value::deserialize(deserializer)?;
886        match &val {
887            serde_json::Value::Number(n) => {
888                if let Some(f) = n.as_f64() {
889                    reject_nan_inf(f).map_err(serde::de::Error::custom)?;
890                    Ok(FlexFloat(f, None))
891                } else {
892                    Err(serde::de::Error::custom("Invalid number"))
893                }
894            }
895            serde_json::Value::String(s) => {
896                let f = s.trim().parse::<f64>().map_err(|_| {
897                    serde::de::Error::custom(format!("Cannot parse '{s}' as float"))
898                })?;
899                reject_nan_inf(f).map_err(serde::de::Error::custom)?;
900                Ok(FlexFloat(f, Some(s.trim().to_string())))
901            }
902            serde_json::Value::Bool(_) => {
903                Err(serde::de::Error::custom("Expected number, got boolean"))
904            }
905            serde_json::Value::Null => Err(serde::de::Error::custom("Expected number, got null")),
906            _ => Err(serde::de::Error::custom("Expected number or string")),
907        }
908    }
909}
910
911impl std::fmt::Display for FlexFloat {
912    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
913        // Note: strict `<` on the upper bound because i64::MAX is not exactly representable
914        // as f64 — `i64::MAX as f64` rounds up to 9223372036854775808.0, and casting that
915        // back via `as i64` would saturate to i64::MAX, producing a wrong display value.
916        if self.0.fract() == 0.0 && self.0 >= i64::MIN as f64 && self.0 < i64::MAX as f64 {
917            write!(f, "{}", self.0 as i64)
918        } else {
919            write!(f, "{}", self.0)
920        }
921    }
922}
923
924/// §2.3 JobIntParameterDefinition
925#[derive(Debug, Clone, Deserialize)]
926#[serde(rename_all = "camelCase", deny_unknown_fields)]
927pub struct JobIntParameterDefinition {
928    pub name: Identifier,
929    pub description: Option<Description>,
930    pub default: Option<FlexInt>,
931
932    #[serde(default)]
933    pub allowed_values: NullableVec<FlexInt>,
934    pub min_value: Option<FlexInt>,
935    pub max_value: Option<FlexInt>,
936    pub user_interface: Option<IntUserInterface>,
937}
938
939impl JobIntParameterDefinition {
940    pub fn check_constraints(&self, value: &str) -> Result<(), String> {
941        let parsed: i64 = value.parse().map_err(|_| {
942            format!(
943                "Parameter '{}': value '{value}' is not a valid integer",
944                self.name
945            )
946        })?;
947        if let Some(min) = &self.min_value {
948            if parsed < min.0 {
949                return Err(format!(
950                    "Parameter '{}': value {parsed} is less than minimum {}",
951                    self.name, min.0
952                ));
953            }
954        }
955        if let Some(max) = &self.max_value {
956            if parsed > max.0 {
957                return Err(format!(
958                    "Parameter '{}': value {parsed} exceeds maximum {}",
959                    self.name, max.0
960                ));
961            }
962        }
963        if let Some(allowed) = self.allowed_values.as_ref() {
964            if !allowed.iter().any(|a| a.0 == parsed) {
965                return Err(format!(
966                    "Parameter '{}': value {parsed} is not in allowed values",
967                    self.name
968                ));
969            }
970        }
971        Ok(())
972    }
973
974    pub fn validate_definition(&self) -> Result<(), Vec<String>> {
975        let mut errors = Vec::new();
976        if let Some(allowed) = self.allowed_values.as_ref() {
977            if allowed.is_empty() {
978                errors.push(format!(
979                    "Parameter '{}': allowedValues must not be empty.",
980                    self.name
981                ));
982            }
983        }
984        if let (Some(min), Some(max)) = (&self.min_value, &self.max_value) {
985            if min.0 > max.0 {
986                errors.push(format!(
987                    "Parameter '{}': minValue ({}) > maxValue ({}).",
988                    self.name, min.0, max.0
989                ));
990            }
991        }
992        if let Some(default) = &self.default {
993            if let Err(e) = self.check_constraints(&default.0.to_string()) {
994                errors.push(e);
995            }
996        }
997        if let Some(ui) = &self.user_interface {
998            errors.extend(validate_ui_label(&ui.label, "label", self.name.as_str()));
999            errors.extend(validate_ui_label(
1000                &ui.group_label,
1001                "groupLabel",
1002                self.name.as_str(),
1003            ));
1004            let control = ui
1005                .control
1006                .as_deref()
1007                .unwrap_or(if self.allowed_values.is_some() {
1008                    "DROPDOWN_LIST"
1009                } else {
1010                    "SPIN_BOX"
1011                });
1012            match control {
1013                "SPIN_BOX" => {
1014                    if self.allowed_values.is_some() {
1015                        errors.push(format!(
1016                            "Parameter '{}': SPIN_BOX cannot be used with allowedValues.",
1017                            self.name
1018                        ));
1019                    }
1020                }
1021                "DROPDOWN_LIST" => {
1022                    if self.allowed_values.is_none() {
1023                        errors.push(format!(
1024                            "Parameter '{}': DROPDOWN_LIST requires allowedValues.",
1025                            self.name
1026                        ));
1027                    }
1028                    if ui.single_step_delta.is_some() {
1029                        errors.push(format!(
1030                            "Parameter '{}': singleStepDelta is only valid with SPIN_BOX.",
1031                            self.name
1032                        ));
1033                    }
1034                }
1035                "HIDDEN" => {
1036                    if ui.single_step_delta.is_some() {
1037                        errors.push(format!(
1038                            "Parameter '{}': singleStepDelta is not valid with HIDDEN.",
1039                            self.name
1040                        ));
1041                    }
1042                }
1043                _ => errors.push(format!(
1044                    "Parameter '{}': unknown control '{control}'.",
1045                    self.name
1046                )),
1047            }
1048            // singleStepDelta must be positive integer
1049            if let Some(delta) = &ui.single_step_delta {
1050                if delta.0 <= 0 {
1051                    errors.push(format!(
1052                        "Parameter '{}': singleStepDelta must be positive.",
1053                        self.name
1054                    ));
1055                }
1056            }
1057        }
1058        if errors.is_empty() {
1059            Ok(())
1060        } else {
1061            Err(errors)
1062        }
1063    }
1064}
1065
1066/// §2.4 JobFloatParameterDefinition
1067#[derive(Debug, Clone, Deserialize)]
1068#[serde(rename_all = "camelCase", deny_unknown_fields)]
1069pub struct JobFloatParameterDefinition {
1070    pub name: Identifier,
1071    pub description: Option<Description>,
1072    pub default: Option<FlexFloat>,
1073
1074    #[serde(default)]
1075    pub allowed_values: NullableVec<FlexFloat>,
1076    pub min_value: Option<FlexFloat>,
1077    pub max_value: Option<FlexFloat>,
1078    pub user_interface: Option<FloatUserInterface>,
1079}
1080
1081impl JobFloatParameterDefinition {
1082    pub fn check_constraints(&self, value: &str) -> Result<(), String> {
1083        let parsed: f64 = value.parse().map_err(|_| {
1084            format!(
1085                "Parameter '{}': value '{value}' is not a valid float",
1086                self.name
1087            )
1088        })?;
1089        reject_nan_inf(parsed).map_err(|e| format!("Parameter '{}': {e}", self.name))?;
1090        if let Some(min) = &self.min_value {
1091            if parsed < min.0 {
1092                return Err(format!(
1093                    "Parameter '{}': value {parsed} is less than minimum {}",
1094                    self.name, min.0
1095                ));
1096            }
1097        }
1098        if let Some(max) = &self.max_value {
1099            if parsed > max.0 {
1100                return Err(format!(
1101                    "Parameter '{}': value {parsed} exceeds maximum {}",
1102                    self.name, max.0
1103                ));
1104            }
1105        }
1106        if let Some(allowed) = self.allowed_values.as_ref() {
1107            if !allowed.iter().any(|a| a.0 == parsed) {
1108                return Err(format!(
1109                    "Parameter '{}': value {parsed} is not in allowed values",
1110                    self.name
1111                ));
1112            }
1113        }
1114        Ok(())
1115    }
1116
1117    pub fn validate_definition(&self) -> Result<(), Vec<String>> {
1118        let mut errors = Vec::new();
1119        if let Some(allowed) = self.allowed_values.as_ref() {
1120            if allowed.is_empty() {
1121                errors.push(format!(
1122                    "Parameter '{}': allowedValues must not be empty.",
1123                    self.name
1124                ));
1125            }
1126            // allowedValues must satisfy min/max constraints
1127            for (i, a) in allowed.iter().enumerate() {
1128                if let Some(min) = &self.min_value {
1129                    if a.0 < min.0 {
1130                        errors.push(format!(
1131                            "Parameter '{}': allowedValues[{i}] ({}) is less than minValue ({}).",
1132                            self.name, a.0, min.0
1133                        ));
1134                    }
1135                }
1136                if let Some(max) = &self.max_value {
1137                    if a.0 > max.0 {
1138                        errors.push(format!(
1139                            "Parameter '{}': allowedValues[{i}] ({}) exceeds maxValue ({}).",
1140                            self.name, a.0, max.0
1141                        ));
1142                    }
1143                }
1144            }
1145        }
1146        if let (Some(min), Some(max)) = (&self.min_value, &self.max_value) {
1147            if min.0 > max.0 {
1148                errors.push(format!(
1149                    "Parameter '{}': minValue ({}) > maxValue ({}).",
1150                    self.name, min.0, max.0
1151                ));
1152            }
1153        }
1154        // Default must satisfy constraints
1155        if let Some(default) = &self.default {
1156            if let Err(e) = self.check_constraints(&default.0.to_string()) {
1157                errors.push(e);
1158            }
1159        }
1160        if let Some(ui) = &self.user_interface {
1161            errors.extend(validate_ui_label(&ui.label, "label", self.name.as_str()));
1162            errors.extend(validate_ui_label(
1163                &ui.group_label,
1164                "groupLabel",
1165                self.name.as_str(),
1166            ));
1167            let control = ui
1168                .control
1169                .as_deref()
1170                .unwrap_or(if self.allowed_values.is_some() {
1171                    "DROPDOWN_LIST"
1172                } else {
1173                    "SPIN_BOX"
1174                });
1175            match control {
1176                "SPIN_BOX" => {
1177                    if self.allowed_values.is_some() {
1178                        errors.push(format!(
1179                            "Parameter '{}': SPIN_BOX cannot be used with allowedValues.",
1180                            self.name
1181                        ));
1182                    }
1183                }
1184                "DROPDOWN_LIST" => {
1185                    if self.allowed_values.is_none() {
1186                        errors.push(format!(
1187                            "Parameter '{}': DROPDOWN_LIST requires allowedValues.",
1188                            self.name
1189                        ));
1190                    }
1191                    if ui.decimals.is_some() {
1192                        errors.push(format!(
1193                            "Parameter '{}': decimals is only valid with SPIN_BOX.",
1194                            self.name
1195                        ));
1196                    }
1197                    if ui.single_step_delta.is_some() {
1198                        errors.push(format!(
1199                            "Parameter '{}': singleStepDelta is only valid with SPIN_BOX.",
1200                            self.name
1201                        ));
1202                    }
1203                }
1204                "HIDDEN" => {
1205                    if ui.decimals.is_some() {
1206                        errors.push(format!(
1207                            "Parameter '{}': decimals is not valid with HIDDEN.",
1208                            self.name
1209                        ));
1210                    }
1211                    if ui.single_step_delta.is_some() {
1212                        errors.push(format!(
1213                            "Parameter '{}': singleStepDelta is not valid with HIDDEN.",
1214                            self.name
1215                        ));
1216                    }
1217                }
1218                _ => errors.push(format!(
1219                    "Parameter '{}': unknown control '{control}'.",
1220                    self.name
1221                )),
1222            }
1223            if let Some(delta) = &ui.single_step_delta {
1224                if delta.0 <= 0.0 {
1225                    errors.push(format!(
1226                        "Parameter '{}': singleStepDelta must be positive.",
1227                        self.name
1228                    ));
1229                }
1230            }
1231        }
1232        if errors.is_empty() {
1233            Ok(())
1234        } else {
1235            Err(errors)
1236        }
1237    }
1238}
1239
1240/// §2.2 JobPathParameterDefinition
1241#[derive(Debug, Clone, Deserialize)]
1242#[serde(rename_all = "camelCase", deny_unknown_fields)]
1243pub struct JobPathParameterDefinition {
1244    pub name: Identifier,
1245    pub description: Option<Description>,
1246    pub default: Option<String>,
1247    pub allowed_values: Option<Vec<String>>,
1248    pub min_length: Option<usize>,
1249    pub max_length: Option<usize>,
1250    pub object_type: Option<ObjectType>,
1251    pub data_flow: Option<DataFlow>,
1252    pub user_interface: Option<PathUserInterface>,
1253}
1254
1255impl JobPathParameterDefinition {
1256    pub fn check_constraints(&self, value: &str) -> Result<(), String> {
1257        let char_len = value.chars().count();
1258        if let Some(min) = self.min_length {
1259            if char_len < min {
1260                return Err(format!(
1261                    "Parameter '{}': value length {} is less than minimum {min}",
1262                    self.name, char_len
1263                ));
1264            }
1265        }
1266        if let Some(max) = self.max_length {
1267            if char_len > max {
1268                return Err(format!(
1269                    "Parameter '{}': value length {} exceeds maximum {max}",
1270                    self.name, char_len
1271                ));
1272            }
1273        }
1274        if let Some(allowed) = self.allowed_values.as_ref() {
1275            if !allowed.iter().any(|a| a == value) {
1276                return Err(format!(
1277                    "Parameter '{}': value '{value}' is not in allowed values",
1278                    self.name
1279                ));
1280            }
1281        }
1282        Ok(())
1283    }
1284
1285    pub fn validate_definition(
1286        &self,
1287        limits: &super::validate_v2023_09::EffectiveLimits,
1288    ) -> Result<(), Vec<String>> {
1289        let mut errors = Vec::new();
1290        let object_type = self.object_type.unwrap_or(ObjectType::Directory);
1291        if let Some(allowed) = self.allowed_values.as_ref() {
1292            if allowed.is_empty() {
1293                errors.push(format!(
1294                    "Parameter '{}': allowedValues must not be empty.",
1295                    self.name
1296                ));
1297            }
1298            for (i, v) in allowed.iter().enumerate() {
1299                let vlen = v.chars().count();
1300                if vlen > limits.max_job_param_string_len {
1301                    errors.push(format!(
1302                        "Parameter '{}': allowedValues[{i}] exceeds {} characters.",
1303                        self.name, limits.max_job_param_string_len
1304                    ));
1305                }
1306                if let Some(min) = self.min_length {
1307                    if vlen < min {
1308                        errors.push(format!(
1309                            "Parameter '{}': allowedValues[{i}] length {vlen} < minLength {min}.",
1310                            self.name,
1311                        ));
1312                    }
1313                }
1314                if let Some(max) = self.max_length {
1315                    if vlen > max {
1316                        errors.push(format!(
1317                            "Parameter '{}': allowedValues[{i}] length {vlen} > maxLength {max}.",
1318                            self.name,
1319                        ));
1320                    }
1321                }
1322            }
1323        }
1324        if let (Some(min), Some(max)) = (self.min_length, self.max_length) {
1325            if min > max {
1326                errors.push(format!(
1327                    "Parameter '{}': minLength ({min}) > maxLength ({max}).",
1328                    self.name
1329                ));
1330            }
1331        }
1332        if let Some(default) = &self.default {
1333            let dlen = default.chars().count();
1334            if dlen > limits.max_job_param_string_len {
1335                errors.push(format!(
1336                    "Parameter '{}': default exceeds {} characters.",
1337                    self.name, limits.max_job_param_string_len
1338                ));
1339            }
1340            if let Some(min) = self.min_length {
1341                if dlen < min {
1342                    errors.push(format!(
1343                        "Parameter '{}': default length {dlen} < minLength {min}.",
1344                        self.name,
1345                    ));
1346                }
1347            }
1348            if let Some(max) = self.max_length {
1349                if dlen > max {
1350                    errors.push(format!(
1351                        "Parameter '{}': default length {dlen} > maxLength {max}.",
1352                        self.name,
1353                    ));
1354                }
1355            }
1356            if let Some(allowed) = self.allowed_values.as_ref() {
1357                if !allowed.contains(default) {
1358                    errors.push(format!(
1359                        "Parameter '{}': default '{}' is not in allowedValues.",
1360                        self.name, default
1361                    ));
1362                }
1363            }
1364        }
1365        if let Some(ui) = &self.user_interface {
1366            errors.extend(validate_ui_label(&ui.label, "label", self.name.as_str()));
1367            errors.extend(validate_ui_label(
1368                &ui.group_label,
1369                "groupLabel",
1370                self.name.as_str(),
1371            ));
1372            let control = ui
1373                .control
1374                .as_deref()
1375                .unwrap_or(if self.allowed_values.is_some() {
1376                    "DROPDOWN_LIST"
1377                } else if object_type == ObjectType::File {
1378                    if self.data_flow == Some(DataFlow::Out) {
1379                        "CHOOSE_OUTPUT_FILE"
1380                    } else {
1381                        "CHOOSE_INPUT_FILE"
1382                    }
1383                } else {
1384                    "CHOOSE_DIRECTORY"
1385                });
1386            match control {
1387                "CHOOSE_INPUT_FILE" | "CHOOSE_OUTPUT_FILE" => {
1388                    if self.allowed_values.is_some() {
1389                        errors.push(format!(
1390                            "Parameter '{}': {control} cannot be used with allowedValues.",
1391                            self.name
1392                        ));
1393                    }
1394                    if object_type == ObjectType::Directory {
1395                        errors.push(format!(
1396                            "Parameter '{}': {control} requires objectType FILE.",
1397                            self.name
1398                        ));
1399                    }
1400                }
1401                "CHOOSE_DIRECTORY" => {
1402                    if self.allowed_values.is_some() {
1403                        errors.push(format!(
1404                            "Parameter '{}': CHOOSE_DIRECTORY cannot be used with allowedValues.",
1405                            self.name
1406                        ));
1407                    }
1408                    if object_type == ObjectType::File {
1409                        errors.push(format!(
1410                            "Parameter '{}': CHOOSE_DIRECTORY requires objectType DIRECTORY.",
1411                            self.name
1412                        ));
1413                    }
1414                }
1415                "DROPDOWN_LIST" => {
1416                    if self.allowed_values.is_none() {
1417                        errors.push(format!(
1418                            "Parameter '{}': DROPDOWN_LIST requires allowedValues.",
1419                            self.name
1420                        ));
1421                    }
1422                }
1423                "HIDDEN" => {}
1424                _ => errors.push(format!(
1425                    "Parameter '{}': unknown control '{control}'.",
1426                    self.name
1427                )),
1428            }
1429            // fileFilters only valid with CHOOSE_INPUT_FILE/CHOOSE_OUTPUT_FILE
1430            let is_file_chooser = control == "CHOOSE_INPUT_FILE" || control == "CHOOSE_OUTPUT_FILE";
1431            if let Some(filters) = &ui.file_filters {
1432                if !is_file_chooser {
1433                    errors.push(format!(
1434                        "Parameter '{}': fileFilters only valid with file chooser controls.",
1435                        self.name
1436                    ));
1437                }
1438                if filters.len() > 20 {
1439                    errors.push(format!(
1440                        "Parameter '{}': fileFilters exceeds 20 elements.",
1441                        self.name
1442                    ));
1443                }
1444                for filter in filters {
1445                    if filter.patterns.is_empty() {
1446                        errors.push(format!(
1447                            "Parameter '{}': fileFilter patterns must not be empty.",
1448                            self.name
1449                        ));
1450                    }
1451                    for pattern in &filter.patterns {
1452                        validate_file_filter_pattern(pattern, self.name.as_str(), &mut errors);
1453                    }
1454                }
1455            }
1456            if let Some(filter) = &ui.file_filter_default {
1457                if !is_file_chooser {
1458                    errors.push(format!(
1459                        "Parameter '{}': fileFilterDefault only valid with file chooser controls.",
1460                        self.name
1461                    ));
1462                }
1463                for pattern in &filter.patterns {
1464                    validate_file_filter_pattern(pattern, self.name.as_str(), &mut errors);
1465                }
1466            }
1467        }
1468        if errors.is_empty() {
1469            Ok(())
1470        } else {
1471            Err(errors)
1472        }
1473    }
1474}
1475
1476fn validate_file_filter_pattern(pattern: &str, param_name: &str, errors: &mut Vec<String>) {
1477    if pattern.is_empty() || pattern.len() > 20 {
1478        errors.push(format!(
1479            "Parameter '{param_name}': file filter pattern must be 1..=20 characters."
1480        ));
1481        return;
1482    }
1483    if pattern == "*" || pattern == "*.*" {
1484        return;
1485    }
1486    if !pattern.starts_with("*.") {
1487        errors.push(format!("Parameter '{param_name}': file filter pattern '{pattern}' must be '*', '*.*', or '*.ext'."));
1488        return;
1489    }
1490    let ext = &pattern[2..];
1491    if ext.is_empty() {
1492        errors.push(format!(
1493            "Parameter '{param_name}': file filter pattern '{pattern}' has empty extension."
1494        ));
1495        return;
1496    }
1497    let disallowed = [
1498        '\\', '/', '*', '?', '[', ']', '#', '%', '&', '{', '}', '<', '>', '$', '!', '\'', '"', ':',
1499        '@', '`', '|', '=',
1500    ];
1501    for ch in ext.chars() {
1502        if ch.is_control() || disallowed.contains(&ch) {
1503            errors.push(format!("Parameter '{param_name}': file filter pattern '{pattern}' contains disallowed character '{ch}'."));
1504            return;
1505        }
1506    }
1507}