Skip to main content

openjd_model/job/create_job/
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//! Parameter merging, preprocessing, and coercion.
6//!
7//! Mirrors Python `_merge_job_parameter.py` and the parameter preprocessing
8//! portion of `_create_job.py`.
9
10use indexmap::IndexMap;
11use openjd_expr::symbol_table::SymbolTable;
12
13use crate::error::ModelError;
14use crate::template::{EnvironmentTemplate, JobParameterDefinition, JobTemplate};
15use crate::types::{
16    DataFlow, JobParameterInputValues, JobParameterType, JobParameterValue, JobParameterValues,
17    ObjectType,
18};
19
20/// Merge parameter definitions from environment templates and the job template.
21///
22/// Per §1.2.1: environment templates are processed in order, job template last.
23/// Type conflicts are errors. Defaults come from the last template that defines one.
24pub fn merge_job_parameter_definitions(
25    job_template: &JobTemplate,
26    environment_templates: &[EnvironmentTemplate],
27) -> Result<Vec<MergedParameterDefinition>, ModelError> {
28    let mut merged: IndexMap<String, MergedParameterDefinition> = IndexMap::new();
29
30    // Helper: process one parameter definition into the merged map.
31    let mut process_param = |p: &JobParameterDefinition, source: &str| -> Result<(), ModelError> {
32        let name = p.name().to_string();
33        if let Some(existing) = merged.get(&name) {
34            if existing.param_type != p.job_param_type() {
35                return Err(ModelError::Compatibility(format!(
36                    "Parameter '{name}' has conflicting types: '{}' in {} and '{}' in {source}",
37                    existing.param_type,
38                    existing.source,
39                    p.type_name()
40                )));
41            }
42            if existing.param_type == JobParameterType::Path {
43                let (new_ot, new_df) = p.path_properties();
44                if let Some(ot) = new_ot {
45                    if let Some(eot) = existing.object_type {
46                        if eot != ot {
47                            return Err(ModelError::Compatibility(format!(
48                                "Parameter '{name}' has conflicting objectType: '{eot}' in {} and '{ot}' in {source}",
49                                existing.source
50                            )));
51                        }
52                    }
53                }
54                if let Some(df) = new_df {
55                    if let Some(edf) = existing.data_flow {
56                        if edf != df {
57                            return Err(ModelError::Compatibility(format!(
58                                "Parameter '{name}' has conflicting dataFlow: '{edf}' in {} and '{df}' in {source}",
59                                existing.source
60                            )));
61                        }
62                    }
63                }
64            }
65        }
66        let default = p.default_value();
67        let (ot, df) = p.path_properties();
68        let src = source.to_string();
69        merged
70            .entry(name.clone())
71            .and_modify(|m| {
72                if let Some(d) = &default {
73                    m.default = Some(d.clone());
74                }
75                if let Some(v) = ot {
76                    m.object_type = Some(v);
77                }
78                if let Some(v) = df {
79                    m.data_flow = Some(v);
80                }
81                m.source = src.clone();
82                m.merge_constraints(p);
83            })
84            .or_insert_with(|| {
85                let mut m = MergedParameterDefinition {
86                    name: name.clone(),
87                    param_type: p.job_param_type(),
88                    default,
89                    object_type: ot,
90                    data_flow: df,
91                    source: src,
92                    min_value_i64: None,
93                    max_value_i64: None,
94                    min_value_f64: None,
95                    max_value_f64: None,
96                    min_length: None,
97                    max_length: None,
98                    allowed_values_int: None,
99                    allowed_values_float: None,
100                    allowed_values_str: None,
101                    item_min_value_i64: None,
102                    item_max_value_i64: None,
103                    item_allowed_values_int: None,
104                    item_min_value_f64: None,
105                    item_max_value_f64: None,
106                    item_allowed_values_float: None,
107                    item_min_length: None,
108                    item_max_length: None,
109                    item_allowed_values_str: None,
110                    item_item_min_value_i64: None,
111                    item_item_max_value_i64: None,
112                    item_item_allowed_values_int: None,
113                };
114                m.merge_constraints(p);
115                m
116            });
117        Ok(())
118    };
119
120    // Process env templates first (in order), job template last
121    for et in environment_templates {
122        let source = format!("EnvironmentTemplate '{}'", et.environment.name);
123        if let Some(params) = &et.parameter_definitions {
124            for p in params {
125                process_param(p, &source)?;
126            }
127        }
128    }
129    let source = "JobTemplate".to_string();
130    for p in job_template.parameter_definitions_list() {
131        process_param(p, &source)?;
132    }
133
134    Ok(merged.into_values().collect())
135}
136
137/// A merged parameter definition from multiple templates.
138///
139/// Constraints are tightened per §1.2.1: allowedValues are intersected,
140/// min values take the maximum, max values take the minimum.
141#[derive(Debug, Clone)]
142pub struct MergedParameterDefinition {
143    pub name: String,
144    pub param_type: JobParameterType,
145    pub default: Option<String>,
146    pub object_type: Option<ObjectType>,
147    pub data_flow: Option<DataFlow>,
148    /// Which template last defined/contributed to this parameter.
149    pub source: String,
150    // Merged constraints (tightened across all templates)
151    pub(crate) min_value_i64: Option<i64>,
152    pub(crate) max_value_i64: Option<i64>,
153    pub(crate) min_value_f64: Option<f64>,
154    pub(crate) max_value_f64: Option<f64>,
155    pub(crate) min_length: Option<usize>,
156    pub(crate) max_length: Option<usize>,
157    pub(crate) allowed_values_int: Option<Vec<i64>>,
158    pub(crate) allowed_values_float: Option<Vec<f64>>,
159    pub(crate) allowed_values_str: Option<Vec<String>>,
160    // List per-item constraints
161    pub(crate) item_min_value_i64: Option<i64>,
162    pub(crate) item_max_value_i64: Option<i64>,
163    pub(crate) item_allowed_values_int: Option<Vec<i64>>,
164    pub(crate) item_min_value_f64: Option<f64>,
165    pub(crate) item_max_value_f64: Option<f64>,
166    pub(crate) item_allowed_values_float: Option<Vec<f64>>,
167    pub(crate) item_min_length: Option<usize>,
168    pub(crate) item_max_length: Option<usize>,
169    pub(crate) item_allowed_values_str: Option<Vec<String>>,
170    pub(crate) item_item_min_value_i64: Option<i64>,
171    pub(crate) item_item_max_value_i64: Option<i64>,
172    pub(crate) item_item_allowed_values_int: Option<Vec<i64>>,
173}
174
175impl MergedParameterDefinition {
176    /// Merge constraints from a parameter definition into this merged definition.
177    fn merge_constraints(&mut self, def: &JobParameterDefinition) {
178        if let Some(v) = def.min_value_i64() {
179            self.min_value_i64 = Some(self.min_value_i64.map_or(v, |cur| cur.max(v)));
180        }
181        if let Some(v) = def.max_value_i64() {
182            self.max_value_i64 = Some(self.max_value_i64.map_or(v, |cur| cur.min(v)));
183        }
184        if let Some(v) = def.min_value_f64() {
185            self.min_value_f64 = Some(self.min_value_f64.map_or(v, |cur| cur.max(v)));
186        }
187        if let Some(v) = def.max_value_f64() {
188            self.max_value_f64 = Some(self.max_value_f64.map_or(v, |cur| cur.min(v)));
189        }
190        if let Some(v) = def.min_length() {
191            self.min_length = Some(self.min_length.map_or(v, |cur| cur.max(v)));
192        }
193        if let Some(v) = def.max_length() {
194            self.max_length = Some(self.max_length.map_or(v, |cur| cur.min(v)));
195        }
196        // allowedValues: intersect
197        if let Some(new_vals) = def.allowed_values_strings() {
198            let new_set: std::collections::HashSet<String> = new_vals.into_iter().collect();
199            self.allowed_values_str = Some(match self.allowed_values_str.take() {
200                Some(cur) => cur.into_iter().filter(|v| new_set.contains(v)).collect(),
201                None => new_set.into_iter().collect(),
202            });
203        }
204        if let Some(new_vals) = def.allowed_values_i64() {
205            let new_set: std::collections::HashSet<i64> = new_vals.into_iter().collect();
206            self.allowed_values_int = Some(match self.allowed_values_int.take() {
207                Some(cur) => cur.into_iter().filter(|v| new_set.contains(v)).collect(),
208                None => new_set.into_iter().collect(),
209            });
210        }
211        if let Some(new_vals) = def.allowed_values_f64() {
212            let new_bits: std::collections::HashSet<u64> =
213                new_vals.iter().map(|f| f.to_bits()).collect();
214            self.allowed_values_float = Some(match self.allowed_values_float.take() {
215                Some(cur) => cur
216                    .into_iter()
217                    .filter(|v| new_bits.contains(&v.to_bits()))
218                    .collect(),
219                None => new_vals,
220            });
221        }
222
223        // Note: list count constraints are merged via min_length()/max_length()
224        // above, which now dispatches to list types as well as string types.
225        if let Some(v) = def.item_min_value_i64() {
226            self.item_min_value_i64 = Some(self.item_min_value_i64.map_or(v, |cur| cur.max(v)));
227        }
228        if let Some(v) = def.item_max_value_i64() {
229            self.item_max_value_i64 = Some(self.item_max_value_i64.map_or(v, |cur| cur.min(v)));
230        }
231        if let Some(new_vals) = def.item_allowed_values_i64() {
232            let new_set: std::collections::HashSet<i64> = new_vals.into_iter().collect();
233            self.item_allowed_values_int = Some(match self.item_allowed_values_int.take() {
234                Some(cur) => cur.into_iter().filter(|v| new_set.contains(v)).collect(),
235                None => new_set.into_iter().collect(),
236            });
237        }
238        if let Some(v) = def.item_min_value_f64() {
239            self.item_min_value_f64 = Some(self.item_min_value_f64.map_or(v, |cur| cur.max(v)));
240        }
241        if let Some(v) = def.item_max_value_f64() {
242            self.item_max_value_f64 = Some(self.item_max_value_f64.map_or(v, |cur| cur.min(v)));
243        }
244        if let Some(new_vals) = def.item_allowed_values_f64() {
245            let new_bits: std::collections::HashSet<u64> =
246                new_vals.iter().map(|f| f.to_bits()).collect();
247            self.item_allowed_values_float = Some(match self.item_allowed_values_float.take() {
248                Some(cur) => cur
249                    .into_iter()
250                    .filter(|v| new_bits.contains(&v.to_bits()))
251                    .collect(),
252                None => new_vals,
253            });
254        }
255        if let Some(v) = def.item_min_length() {
256            self.item_min_length = Some(self.item_min_length.map_or(v, |cur| cur.max(v)));
257        }
258        if let Some(v) = def.item_max_length() {
259            self.item_max_length = Some(self.item_max_length.map_or(v, |cur| cur.min(v)));
260        }
261        if let Some(new_vals) = def.item_allowed_values_strings() {
262            let new_set: std::collections::HashSet<String> = new_vals.into_iter().collect();
263            self.item_allowed_values_str = Some(match self.item_allowed_values_str.take() {
264                Some(cur) => cur.into_iter().filter(|v| new_set.contains(v)).collect(),
265                None => new_set.into_iter().collect(),
266            });
267        }
268        if let Some(v) = def.item_item_min_value_i64() {
269            self.item_item_min_value_i64 =
270                Some(self.item_item_min_value_i64.map_or(v, |cur| cur.max(v)));
271        }
272        if let Some(v) = def.item_item_max_value_i64() {
273            self.item_item_max_value_i64 =
274                Some(self.item_item_max_value_i64.map_or(v, |cur| cur.min(v)));
275        }
276        if let Some(new_vals) = def.item_item_allowed_values_i64() {
277            let new_set: std::collections::HashSet<i64> = new_vals.into_iter().collect();
278            self.item_item_allowed_values_int =
279                Some(match self.item_item_allowed_values_int.take() {
280                    Some(cur) => cur.into_iter().filter(|v| new_set.contains(v)).collect(),
281                    None => new_set.into_iter().collect(),
282                });
283        }
284    }
285
286    /// Validate that the merged constraints are satisfiable (§1.2.1).
287    pub fn validate_satisfiable(&self) -> Result<(), ModelError> {
288        if let (Some(min), Some(max)) = (self.min_value_i64, self.max_value_i64) {
289            if min > max {
290                return Err(ModelError::Compatibility(format!(
291                    "Parameter '{}': merged INT constraints have no valid range (min {min} > max {max})", self.name)));
292            }
293        }
294        if let (Some(min), Some(max)) = (self.min_value_f64, self.max_value_f64) {
295            if min > max {
296                return Err(ModelError::Compatibility(format!(
297                    "Parameter '{}': merged FLOAT constraints have no valid range (min {min} > max {max})", self.name)));
298            }
299        }
300        if let (Some(min), Some(max)) = (self.min_length, self.max_length) {
301            if min > max {
302                return Err(ModelError::Compatibility(format!(
303                    "Parameter '{}': merged {} constraints have no valid length (minLength {min} > maxLength {max})",
304                    self.name, self.param_type)));
305            }
306        }
307        if let Some(allowed) = &self.allowed_values_str {
308            if allowed.is_empty() {
309                return Err(ModelError::Compatibility(format!(
310                    "Parameter '{}': merged {} allowedValues have no common values",
311                    self.name, self.param_type
312                )));
313            }
314            if let Some(def) = &self.default {
315                if !allowed.iter().any(|a| a == def) {
316                    return Err(ModelError::Compatibility(format!(
317                        "Parameter '{}': default '{}' not in merged allowedValues",
318                        self.name, def
319                    )));
320                }
321            }
322        }
323        if let Some(allowed) = &self.allowed_values_int {
324            if allowed.is_empty() {
325                return Err(ModelError::Compatibility(format!(
326                    "Parameter '{}': merged INT allowedValues have no common values",
327                    self.name
328                )));
329            }
330        }
331        if let Some(allowed) = &self.allowed_values_float {
332            if allowed.is_empty() {
333                return Err(ModelError::Compatibility(format!(
334                    "Parameter '{}': merged FLOAT allowedValues have no common values",
335                    self.name
336                )));
337            }
338        }
339        Ok(())
340    }
341
342    /// Check a coerced value against the merged constraints.
343    pub fn check_constraints(&self, value: &openjd_expr::ExprValue) -> Result<(), ModelError> {
344        match value {
345            openjd_expr::ExprValue::Int(v) => {
346                if let Some(min) = self.min_value_i64 {
347                    if *v < min {
348                        return Err(ModelError::DecodeValidation(format!(
349                            "Parameter '{}': value {v} is less than minimum {min}",
350                            self.name
351                        )));
352                    }
353                }
354                if let Some(max) = self.max_value_i64 {
355                    if *v > max {
356                        return Err(ModelError::DecodeValidation(format!(
357                            "Parameter '{}': value {v} exceeds maximum {max}",
358                            self.name
359                        )));
360                    }
361                }
362                if let Some(allowed) = &self.allowed_values_int {
363                    if !allowed.contains(v) {
364                        return Err(ModelError::DecodeValidation(format!(
365                            "Parameter '{}': value {v} is not in allowed values",
366                            self.name
367                        )));
368                    }
369                }
370            }
371            openjd_expr::ExprValue::Float(v) => {
372                let f = v.value();
373                if let Some(min) = self.min_value_f64 {
374                    if f < min {
375                        return Err(ModelError::DecodeValidation(format!(
376                            "Parameter '{}': value {f} is less than minimum {min}",
377                            self.name
378                        )));
379                    }
380                }
381                if let Some(max) = self.max_value_f64 {
382                    if f > max {
383                        return Err(ModelError::DecodeValidation(format!(
384                            "Parameter '{}': value {f} exceeds maximum {max}",
385                            self.name
386                        )));
387                    }
388                }
389                if let Some(allowed) = &self.allowed_values_float {
390                    if !allowed.contains(&f) {
391                        return Err(ModelError::DecodeValidation(format!(
392                            "Parameter '{}': value {f} is not in allowed values",
393                            self.name
394                        )));
395                    }
396                }
397            }
398            openjd_expr::ExprValue::String(v) | openjd_expr::ExprValue::Path { value: v, .. } => {
399                if let Some(min) = self.min_length {
400                    if v.len() < min {
401                        return Err(ModelError::DecodeValidation(format!(
402                            "Parameter '{}': value length {} is less than minimum {min}",
403                            self.name,
404                            v.len()
405                        )));
406                    }
407                }
408                if let Some(max) = self.max_length {
409                    if v.len() > max {
410                        return Err(ModelError::DecodeValidation(format!(
411                            "Parameter '{}': value length {} exceeds maximum {max}",
412                            self.name,
413                            v.len()
414                        )));
415                    }
416                }
417                if let Some(allowed) = &self.allowed_values_str {
418                    if !allowed.iter().any(|a| a == v) {
419                        return Err(ModelError::DecodeValidation(format!(
420                            "Parameter '{}': value '{}' is not in allowed values",
421                            self.name, v
422                        )));
423                    }
424                }
425            }
426            openjd_expr::ExprValue::ListBool(items) => {
427                if let Some(min) = self.min_length {
428                    if items.len() < min {
429                        return Err(ModelError::DecodeValidation(format!(
430                            "Parameter '{}': list length {} is less than minimum {min}",
431                            self.name,
432                            items.len()
433                        )));
434                    }
435                }
436                if let Some(max) = self.max_length {
437                    if items.len() > max {
438                        return Err(ModelError::DecodeValidation(format!(
439                            "Parameter '{}': list length {} exceeds maximum {max}",
440                            self.name,
441                            items.len()
442                        )));
443                    }
444                }
445            }
446            openjd_expr::ExprValue::ListInt(items) => {
447                if let Some(min) = self.min_length {
448                    if items.len() < min {
449                        return Err(ModelError::DecodeValidation(format!(
450                            "Parameter '{}': list length {} is less than minimum {min}",
451                            self.name,
452                            items.len()
453                        )));
454                    }
455                }
456                if let Some(max) = self.max_length {
457                    if items.len() > max {
458                        return Err(ModelError::DecodeValidation(format!(
459                            "Parameter '{}': list length {} exceeds maximum {max}",
460                            self.name,
461                            items.len()
462                        )));
463                    }
464                }
465                for (i, v) in items.iter().enumerate() {
466                    if let Some(min) = self.item_min_value_i64 {
467                        if *v < min {
468                            return Err(ModelError::DecodeValidation(format!(
469                                "Parameter '{}': item[{i}] value {v} is less than minimum {min}",
470                                self.name
471                            )));
472                        }
473                    }
474                    if let Some(max) = self.item_max_value_i64 {
475                        if *v > max {
476                            return Err(ModelError::DecodeValidation(format!(
477                                "Parameter '{}': item[{i}] value {v} exceeds maximum {max}",
478                                self.name
479                            )));
480                        }
481                    }
482                    if let Some(allowed) = &self.item_allowed_values_int {
483                        if !allowed.contains(v) {
484                            return Err(ModelError::DecodeValidation(format!(
485                                "Parameter '{}': item[{i}] value {v} is not in allowed values",
486                                self.name
487                            )));
488                        }
489                    }
490                }
491            }
492            openjd_expr::ExprValue::ListFloat(items) => {
493                if let Some(min) = self.min_length {
494                    if items.len() < min {
495                        return Err(ModelError::DecodeValidation(format!(
496                            "Parameter '{}': list length {} is less than minimum {min}",
497                            self.name,
498                            items.len()
499                        )));
500                    }
501                }
502                if let Some(max) = self.max_length {
503                    if items.len() > max {
504                        return Err(ModelError::DecodeValidation(format!(
505                            "Parameter '{}': list length {} exceeds maximum {max}",
506                            self.name,
507                            items.len()
508                        )));
509                    }
510                }
511                for (i, v) in items.iter().enumerate() {
512                    let f = v.value();
513                    if let Some(min) = self.item_min_value_f64 {
514                        if f < min {
515                            return Err(ModelError::DecodeValidation(format!(
516                                "Parameter '{}': item[{i}] value {f} is less than minimum {min}",
517                                self.name
518                            )));
519                        }
520                    }
521                    if let Some(max) = self.item_max_value_f64 {
522                        if f > max {
523                            return Err(ModelError::DecodeValidation(format!(
524                                "Parameter '{}': item[{i}] value {f} exceeds maximum {max}",
525                                self.name
526                            )));
527                        }
528                    }
529                    if let Some(allowed) = &self.item_allowed_values_float {
530                        if !allowed.contains(&f) {
531                            return Err(ModelError::DecodeValidation(format!(
532                                "Parameter '{}': item[{i}] value {f} is not in allowed values",
533                                self.name
534                            )));
535                        }
536                    }
537                }
538            }
539            openjd_expr::ExprValue::ListString(items, _)
540            | openjd_expr::ExprValue::ListPath(items, _, _) => {
541                if let Some(min) = self.min_length {
542                    if items.len() < min {
543                        return Err(ModelError::DecodeValidation(format!(
544                            "Parameter '{}': list length {} is less than minimum {min}",
545                            self.name,
546                            items.len()
547                        )));
548                    }
549                }
550                if let Some(max) = self.max_length {
551                    if items.len() > max {
552                        return Err(ModelError::DecodeValidation(format!(
553                            "Parameter '{}': list length {} exceeds maximum {max}",
554                            self.name,
555                            items.len()
556                        )));
557                    }
558                }
559                for (i, s) in items.iter().enumerate() {
560                    let char_len = s.chars().count();
561                    if let Some(min) = self.item_min_length {
562                        if char_len < min {
563                            return Err(ModelError::DecodeValidation(format!(
564                                "Parameter '{}': item[{i}] length {char_len} is less than minimum {min}",
565                                self.name
566                            )));
567                        }
568                    }
569                    if let Some(max) = self.item_max_length {
570                        if char_len > max {
571                            return Err(ModelError::DecodeValidation(format!(
572                                "Parameter '{}': item[{i}] length {char_len} exceeds maximum {max}",
573                                self.name
574                            )));
575                        }
576                    }
577                    if let Some(allowed) = &self.item_allowed_values_str {
578                        if !allowed.iter().any(|a| a == s) {
579                            return Err(ModelError::DecodeValidation(format!(
580                                "Parameter '{}': item[{i}] value '{}' is not in allowed values",
581                                self.name, s
582                            )));
583                        }
584                    }
585                }
586            }
587            openjd_expr::ExprValue::ListList(items, _, _) => {
588                if let Some(min) = self.min_length {
589                    if items.len() < min {
590                        return Err(ModelError::DecodeValidation(format!(
591                            "Parameter '{}': list length {} is less than minimum {min}",
592                            self.name,
593                            items.len()
594                        )));
595                    }
596                }
597                if let Some(max) = self.max_length {
598                    if items.len() > max {
599                        return Err(ModelError::DecodeValidation(format!(
600                            "Parameter '{}': list length {} exceeds maximum {max}",
601                            self.name,
602                            items.len()
603                        )));
604                    }
605                }
606                for (i, inner) in items.iter().enumerate() {
607                    if let openjd_expr::ExprValue::ListInt(ints) = inner {
608                        if let Some(min) = self.item_min_length {
609                            if ints.len() < min {
610                                return Err(ModelError::DecodeValidation(format!(
611                                    "Parameter '{}': item[{i}] length {} is less than minimum {min}",
612                                    self.name,
613                                    ints.len()
614                                )));
615                            }
616                        }
617                        if let Some(max) = self.item_max_length {
618                            if ints.len() > max {
619                                return Err(ModelError::DecodeValidation(format!(
620                                    "Parameter '{}': item[{i}] length {} exceeds maximum {max}",
621                                    self.name,
622                                    ints.len()
623                                )));
624                            }
625                        }
626                        for (j, v) in ints.iter().enumerate() {
627                            if let Some(min) = self.item_item_min_value_i64 {
628                                if *v < min {
629                                    return Err(ModelError::DecodeValidation(format!(
630                                        "Parameter '{}': item[{i}][{j}] value {v} is less than minimum {min}",
631                                        self.name
632                                    )));
633                                }
634                            }
635                            if let Some(max) = self.item_item_max_value_i64 {
636                                if *v > max {
637                                    return Err(ModelError::DecodeValidation(format!(
638                                        "Parameter '{}': item[{i}][{j}] value {v} exceeds maximum {max}",
639                                        self.name
640                                    )));
641                                }
642                            }
643                            if let Some(allowed) = &self.item_item_allowed_values_int {
644                                if !allowed.contains(v) {
645                                    return Err(ModelError::DecodeValidation(format!(
646                                        "Parameter '{}': item[{i}][{j}] value {v} is not in allowed values",
647                                        self.name
648                                    )));
649                                }
650                            }
651                        }
652                    }
653                }
654            }
655            _ => {} // BOOL, RANGE_EXPR — no cross-template mergeable constraints
656        }
657        Ok(())
658    }
659}
660
661/// Coerce an `ExprValue` to the target `JobParameterType`.
662pub(super) fn coerce_to_type(
663    value: &openjd_expr::ExprValue,
664    param_type: JobParameterType,
665) -> Result<openjd_expr::ExprValue, String> {
666    use openjd_expr::ExprValue;
667
668    if value_matches_type(value, param_type) {
669        return Ok(value.clone());
670    }
671
672    match (value, param_type) {
673        (ExprValue::Int(i), JobParameterType::Float) => {
674            return Ok(ExprValue::Float(
675                openjd_expr::value::Float64::new(*i as f64)
676                    .map_err(|_| format!("Cannot represent integer {i} as a finite float"))?,
677            ));
678        }
679        (ExprValue::Float(f), JobParameterType::Int) => {
680            let v = f.value();
681            if v.fract() == 0.0 && v >= i64::MIN as f64 && v < i64::MAX as f64 {
682                return Ok(ExprValue::Int(v as i64));
683            }
684        }
685        _ => {}
686    }
687
688    let s = match value {
689        ExprValue::String(s) => s.as_str(),
690        ExprValue::Int(i) => {
691            return coerce_from_str(&i.to_string(), param_type);
692        }
693        ExprValue::Float(f) => {
694            return coerce_from_str(&f.to_string(), param_type);
695        }
696        ExprValue::Bool(b) => {
697            return coerce_from_str(if *b { "true" } else { "false" }, param_type);
698        }
699        other => {
700            return Err(format!(
701                "Cannot coerce {} to {}",
702                other.type_name(),
703                param_type.as_spec_str()
704            ));
705        }
706    };
707    coerce_from_str(s, param_type)
708}
709
710/// Coerce a string value to the target type.
711pub(super) fn coerce_from_str(
712    s: &str,
713    param_type: JobParameterType,
714) -> Result<openjd_expr::ExprValue, String> {
715    use openjd_expr::ExprValue;
716    Ok(match param_type {
717        JobParameterType::Int => s
718            .parse::<i64>()
719            .map(ExprValue::Int)
720            .map_err(|_| format!("Value '{s}' is not a valid integer or integer string."))?,
721        JobParameterType::Float => {
722            let f = s
723                .parse::<f64>()
724                .map_err(|_| format!("Value '{s}' is not a valid float."))?;
725            ExprValue::Float(
726                openjd_expr::value::Float64::with_str(f, s.to_string())
727                    .map_err(|_| format!("Value '{s}' is not a valid float."))?,
728            )
729        }
730        JobParameterType::Bool => match s.to_lowercase().as_str() {
731            "true" | "yes" | "on" | "1" => ExprValue::Bool(true),
732            "false" | "no" | "off" | "0" => ExprValue::Bool(false),
733            _ => {
734                return Err(format!(
735                    "Value '{}' is not a valid boolean. Accepted: true/false, 1/0, yes/no, on/off.",
736                    s
737                ))
738            }
739        },
740        JobParameterType::RangeExpr => match s.parse::<openjd_expr::RangeExpr>() {
741            Ok(r) => ExprValue::RangeExpr(r),
742            Err(e) => return Err(format!("Value '{s}' is not a valid range expression: {e}")),
743        },
744        JobParameterType::Path | JobParameterType::String => ExprValue::String(s.to_string()),
745        JobParameterType::ListString
746        | JobParameterType::ListInt
747        | JobParameterType::ListFloat
748        | JobParameterType::ListPath
749        | JobParameterType::ListBool
750        | JobParameterType::ListListInt => {
751            // Try parsing the string as JSON for list parameter coercion.
752            if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(s) {
753                json_to_expr_value(&json_val)?
754            } else {
755                return Err(format!(
756                    "Value '{s}' is not valid JSON for a list parameter."
757                ));
758            }
759        }
760    })
761}
762
763fn value_matches_type(value: &openjd_expr::ExprValue, param_type: JobParameterType) -> bool {
764    use openjd_expr::ExprValue;
765    matches!(
766        (value, param_type),
767        (
768            ExprValue::String(_),
769            JobParameterType::String | JobParameterType::Path
770        ) | (ExprValue::Int(_), JobParameterType::Int)
771            | (ExprValue::Float(_), JobParameterType::Float)
772            | (ExprValue::Bool(_), JobParameterType::Bool)
773            | (ExprValue::RangeExpr(_), JobParameterType::RangeExpr)
774            | (
775                ExprValue::ListString(_, _),
776                JobParameterType::ListString | JobParameterType::ListPath
777            )
778            | (ExprValue::ListInt(_), JobParameterType::ListInt)
779            | (ExprValue::ListFloat(_), JobParameterType::ListFloat)
780            | (ExprValue::ListBool(_), JobParameterType::ListBool)
781            | (ExprValue::ListList(_, _, _), JobParameterType::ListListInt)
782    )
783}
784
785/// Options controlling how PATH parameters are resolved in [`preprocess_job_parameters`].
786pub struct PathParameterOptions<'a> {
787    /// Directory containing the job template. Relative PATH defaults are joined to this.
788    pub job_template_dir: &'a str,
789    /// Current working directory. Relative PATH user values are joined to this.
790    pub current_working_dir: &'a str,
791    /// How path strings are interpreted for absolute/relative checks.
792    /// Use `PathFormat::host()` for local filesystem paths, or `PathFormat::Posix` /
793    /// `PathFormat::Windows` when paths originate from a known platform.
794    pub path_format: openjd_expr::path_mapping::PathFormat,
795    /// If `false`, PATH defaults must be relative and within `job_template_dir`.
796    /// If `true`, absolute defaults and `..` walk-up are permitted.
797    pub allow_template_dir_walk_up: bool,
798    /// If `true`, URI values (`scheme://...`) in PATH parameters are preserved as-is
799    /// (requires EXPR extension). If `false` with EXPR, URIs are rejected with an error.
800    /// Without EXPR, this flag is ignored — URIs are treated as opaque relative strings.
801    pub allow_uri_path_values: bool,
802}
803
804impl<'a> PathParameterOptions<'a> {
805    /// Create options with sensible defaults: host path format, no walk-up, no URIs.
806    pub fn new(job_template_dir: &'a str, current_working_dir: &'a str) -> Self {
807        Self {
808            job_template_dir,
809            current_working_dir,
810            path_format: openjd_expr::path_mapping::PathFormat::host(),
811            allow_template_dir_walk_up: false,
812            allow_uri_path_values: false,
813        }
814    }
815}
816
817/// Preprocess job parameters: validate inputs, fill defaults, check constraints.
818///
819/// Errors are accumulated rather than fail-fast: a single bad parameter
820/// won't mask later problems. The returned `ModelError::DecodeValidation`
821/// contains all collected messages joined by newlines, in this order:
822///
823/// 1. Per-parameter satisfiability errors (constraint conflicts after merging).
824/// 2. Per-parameter input handling errors (bad coercion, constraint violations,
825///    forbidden URI paths) and per-parameter default handling errors.
826/// 3. A single "extra parameters" line for inputs that don't match any defined
827///    parameter (Pythonish behavior: one line listing them all).
828/// 4. A single "missing required" line for parameters with no value or default.
829///
830/// Preconditions that prevent any per-parameter work — a non-absolute
831/// `job_template_dir` and structural failures from
832/// [`merge_job_parameter_definitions`] — still fail fast.
833pub fn preprocess_job_parameters(
834    job_template: &JobTemplate,
835    input_values: &JobParameterInputValues,
836    environment_templates: &[EnvironmentTemplate],
837    path_options: &PathParameterOptions<'_>,
838) -> Result<JobParameterValues, ModelError> {
839    let job_template_dir = path_options.job_template_dir;
840    let current_working_dir = path_options.current_working_dir;
841    let path_format = path_options.path_format;
842    let allow_job_template_dir_walk_up = path_options.allow_template_dir_walk_up;
843    let allow_uri_path_values = path_options.allow_uri_path_values;
844
845    if !allow_job_template_dir_walk_up && !is_absolute_for_format(job_template_dir, path_format) {
846        return Err(ModelError::DecodeValidation(format!(
847            "The value supplied for the job template dir, {job_template_dir}, is not an absolute path. \
848             It must be absolute to enforce that PATH parameter defaults are always inside the job template dir.",
849        )));
850    }
851
852    let merged = merge_job_parameter_definitions(job_template, environment_templates)?;
853
854    let mut errors: Vec<String> = Vec::new();
855
856    for param in &merged {
857        if let Err(e) = param.validate_satisfiable() {
858            errors.push(model_err_message(e));
859        }
860    }
861
862    let mut result = JobParameterValues::new();
863    let mut missing = Vec::new();
864
865    let has_expr = job_template
866        .extensions
867        .as_ref()
868        .is_some_and(|exts| exts.iter().any(|e| e.as_str() == "EXPR"));
869
870    for param in &merged {
871        let param_type = param.param_type;
872        if let Some(input_val) = input_values.get(&param.name) {
873            let coerced_opt: Option<openjd_expr::ExprValue> =
874                if param.param_type == JobParameterType::Path {
875                    let s = input_val.as_str_repr();
876                    if !s.is_empty() && has_expr && openjd_expr::uri_path::is_uri(&s) {
877                        // EXPR extension: URI handling depends on allow_uri_path_values
878                        if !allow_uri_path_values {
879                            errors.push(format!(
880                                "Parameter '{}': URI path values are not permitted. Got '{}'",
881                                param.name, s
882                            ));
883                            None
884                        } else {
885                            Some(input_val.clone())
886                        }
887                    } else if !(s.is_empty() || is_absolute_for_format_no_uri(&s, path_format)) {
888                        // Relative path: join with current_working_dir if non-empty.
889                        if current_working_dir.is_empty() {
890                            Some(input_val.clone())
891                        } else {
892                            Some(openjd_expr::ExprValue::String(
893                                openjd_expr::functions::path::non_uri_join(
894                                    current_working_dir,
895                                    &s,
896                                    path_format,
897                                ),
898                            ))
899                        }
900                    } else {
901                        Some(input_val.clone())
902                    }
903                } else {
904                    Some(input_val.clone())
905                };
906            let Some(coerced) = coerced_opt else { continue };
907            match coerce_to_type(&coerced, param_type) {
908                Ok(expr_value) => {
909                    if let Err(e) = param.check_constraints(&expr_value) {
910                        errors.push(model_err_message(e));
911                    } else {
912                        result.insert(
913                            param.name.clone(),
914                            JobParameterValue {
915                                param_type,
916                                value: expr_value,
917                            },
918                        );
919                    }
920                }
921                Err(e) => {
922                    errors.push(format!("Parameter '{}': {e}", param.name));
923                }
924            }
925        } else if let Some(default) = &param.default {
926            let value_str_opt: Option<String> = if param.param_type == JobParameterType::Path
927                && !default.is_empty()
928            {
929                if has_expr && allow_uri_path_values && openjd_expr::uri_path::is_uri(default) {
930                    // EXPR + allow: URI preserved as-is
931                    Some(default.clone())
932                } else if has_expr
933                    && !allow_uri_path_values
934                    && openjd_expr::uri_path::is_uri(default)
935                {
936                    errors.push(format!(
937                        "Parameter '{}': URI path values are not permitted in defaults. Got '{}'",
938                        param.name, default
939                    ));
940                    None
941                } else if is_absolute_for_format_no_uri(default, path_format) {
942                    if !allow_job_template_dir_walk_up {
943                        errors.push(format!(
944                            "The default value of PATH parameter {} is an absolute path. Default paths must be relative, and are joined to the job template's directory.",
945                            param.name
946                        ));
947                        None
948                    } else {
949                        Some(default.clone())
950                    }
951                } else if !allow_job_template_dir_walk_up
952                    && is_absolute_for_format(job_template_dir, path_format)
953                {
954                    let joined = join_for_format(job_template_dir, default, path_format);
955                    let normalized = normalize_path_str(&joined, path_format);
956                    let normalized_dir = normalize_path_str(job_template_dir, path_format);
957                    if !normalized.starts_with(&normalized_dir) {
958                        errors.push(format!(
959                            "The default value of PATH parameter {} references a path outside of the template directory. Walking up from the template directory is not permitted.",
960                            param.name
961                        ));
962                        None
963                    } else {
964                        Some(normalized)
965                    }
966                } else if is_absolute_for_format(job_template_dir, path_format) {
967                    let joined = join_for_format(job_template_dir, default, path_format);
968                    Some(normalize_path_str(&joined, path_format))
969                } else {
970                    Some(default.clone())
971                }
972            } else {
973                Some(default.clone())
974            };
975            let Some(value_str) = value_str_opt else {
976                continue;
977            };
978            match coerce_from_str(&value_str, param_type) {
979                Ok(expr_value) => {
980                    result.insert(
981                        param.name.clone(),
982                        JobParameterValue {
983                            param_type,
984                            value: expr_value,
985                        },
986                    );
987                }
988                Err(e) => errors.push(format!("Parameter '{}': {e}", param.name)),
989            }
990        } else {
991            missing.push(param.name.clone());
992        }
993    }
994
995    let mut extras: Vec<&str> = input_values
996        .keys()
997        .filter(|k| !merged.iter().any(|p| p.name == **k))
998        .map(String::as_str)
999        .collect();
1000    if !extras.is_empty() {
1001        extras.sort();
1002        errors.push(format!(
1003            "Job parameter values provided for parameters that are not defined in the template: {}",
1004            extras.join(", ")
1005        ));
1006    }
1007
1008    if !missing.is_empty() {
1009        missing.sort();
1010        errors.push(format!(
1011            "Values missing for required job parameters: {}",
1012            missing.join(", ")
1013        ));
1014    }
1015
1016    if !errors.is_empty() {
1017        return Err(ModelError::DecodeValidation(errors.join("\n")));
1018    }
1019
1020    Ok(result)
1021}
1022
1023/// Extract a flat string from a `ModelError`.
1024///
1025/// `validate_satisfiable` and `check_constraints` may return either
1026/// `DecodeValidation` or `Compatibility` variants; both carry their
1027/// human-readable text directly. For other variants we fall back to
1028/// `Display`, which is fine because those cases don't occur in this code
1029/// path today.
1030fn model_err_message(e: ModelError) -> String {
1031    match e {
1032        ModelError::DecodeValidation(msg) | ModelError::Compatibility(msg) => msg,
1033        other => other.to_string(),
1034    }
1035}
1036
1037/// Check whether a path string is absolute according to the given path format.
1038///
1039/// Delegates to `openjd_expr::functions::path::is_absolute` which handles
1040/// URIs (`scheme://...`), UNC paths (`\\server`), POSIX (`/...`), and
1041/// Windows drive letters (`C:\...`).
1042fn is_absolute_for_format(s: &str, format: openjd_expr::path_mapping::PathFormat) -> bool {
1043    openjd_expr::functions::path::is_absolute(s, format)
1044}
1045
1046/// Like `is_absolute_for_format` but does NOT recognize URIs as absolute.
1047/// Used for PATH parameter values when EXPR is not enabled — URIs should be
1048/// treated as opaque relative strings.
1049fn is_absolute_for_format_no_uri(s: &str, format: openjd_expr::path_mapping::PathFormat) -> bool {
1050    if openjd_expr::uri_path::is_uri(s) {
1051        return false;
1052    }
1053    openjd_expr::functions::path::is_absolute(s, format)
1054}
1055
1056/// Join two path strings using the separator and absoluteness rules for `format`.
1057fn join_for_format(
1058    base: &str,
1059    relative: &str,
1060    format: openjd_expr::path_mapping::PathFormat,
1061) -> String {
1062    openjd_expr::functions::path::join(base, relative, format)
1063}
1064
1065/// Normalize a path string by resolving `.` and `..` components.
1066/// Uses string-based logic so it works correctly regardless of host OS.
1067///
1068/// # Limitations
1069///
1070/// This is a **string-level** normalization only. It does **not** resolve
1071/// symlinks, so a path like `./symlink_to_parent/../secret` may normalize
1072/// to a location that appears to be within the base directory but actually
1073/// escapes it via the symlink target. Callers that use the result for
1074/// access-control checks (e.g. the `starts_with(normalized_dir)` guard in
1075/// `preprocess_job_parameters`) should be aware that filesystem-level
1076/// canonicalization (e.g. `std::fs::canonicalize`) is needed downstream
1077/// before performing actual file I/O to prevent symlink-based traversal.
1078fn normalize_path_str(path: &str, format: openjd_expr::path_mapping::PathFormat) -> String {
1079    use openjd_expr::path_mapping::PathFormat;
1080    let sep = match format {
1081        PathFormat::Windows => '\\',
1082        PathFormat::Posix | PathFormat::Uri => '/',
1083    };
1084
1085    // Detect and preserve the root prefix
1086    let (root, rest, min_components) = if path.len() >= 3
1087        && path.as_bytes()[0].is_ascii_alphabetic()
1088        && path.as_bytes()[1] == b':'
1089        && (path.as_bytes()[2] == b'\\' || path.as_bytes()[2] == b'/')
1090    {
1091        // Windows drive root: "C:\" or "C:/"
1092        let root = format!("{}:{sep}", path.chars().next().unwrap());
1093        (root, &path[3..], 0)
1094    } else if path.starts_with("\\\\") || path.starts_with("//") {
1095        // UNC path — server and share components must be preserved
1096        (format!("{sep}{sep}"), &path[2..], 2)
1097    } else if path.starts_with('/') || path.starts_with('\\') {
1098        (sep.to_string(), &path[1..], 0)
1099    } else {
1100        (String::new(), path, 0)
1101    };
1102
1103    let mut components: Vec<&str> = Vec::new();
1104    for part in rest.split(['/', '\\']) {
1105        match part {
1106            ".." => {
1107                if components.len() > min_components {
1108                    components.pop();
1109                }
1110            }
1111            "." | "" => {}
1112            _ => components.push(part),
1113        }
1114    }
1115    format!("{root}{}", components.join(&sep.to_string()))
1116}
1117
1118/// Convert a serde_json::Value to an ExprValue.
1119pub(super) fn json_to_expr_value(
1120    val: &serde_json::Value,
1121) -> Result<openjd_expr::ExprValue, String> {
1122    match val {
1123        serde_json::Value::Null => {
1124            Err("Unexpected null in parameter value. List elements must be strings, integers, floats, or booleans.".to_string())
1125        }
1126        serde_json::Value::Bool(b) => Ok(openjd_expr::ExprValue::Bool(*b)),
1127        serde_json::Value::Number(n) => {
1128            if let Some(i) = n.as_i64() {
1129                Ok(openjd_expr::ExprValue::Int(i))
1130            } else if let Some(f) = n.as_f64() {
1131                openjd_expr::value::Float64::new(f)
1132                    .map(openjd_expr::ExprValue::Float)
1133                    .map_err(|_| format!("Float value {f} is not finite"))
1134            } else {
1135                Ok(openjd_expr::ExprValue::String(n.to_string()))
1136            }
1137        }
1138        serde_json::Value::String(s) => Ok(openjd_expr::ExprValue::String(s.clone())),
1139        serde_json::Value::Array(arr) => {
1140            let elements: Vec<openjd_expr::ExprValue> = arr
1141                .iter()
1142                .map(json_to_expr_value)
1143                .collect::<Result<_, _>>()?;
1144            openjd_expr::ExprValue::make_list(elements, openjd_expr::ExprType::NULLTYPE)
1145                .map_err(|e| format!("Invalid list value: {e}"))
1146        }
1147        serde_json::Value::Object(_) => {
1148            Err("Unexpected JSON object in parameter value. List elements must be strings, integers, floats, or booleans.".to_string())
1149        }
1150    }
1151}
1152
1153/// Build a symbol table from processed job parameter values.
1154pub fn build_symbol_table(params: &JobParameterValues) -> Result<SymbolTable, ModelError> {
1155    let mut symtab = SymbolTable::new();
1156    for (name, pv) in params {
1157        // PATH and LIST[PATH] Param.* are excluded from the template-scope symtab.
1158        // They are host-context only (concrete values depend on session-time path mapping).
1159        let is_path = matches!(
1160            pv.param_type,
1161            JobParameterType::Path | JobParameterType::ListPath
1162        );
1163        if !is_path {
1164            symtab.set(&format!("Param.{name}"), pv.value.clone())?;
1165        }
1166
1167        let raw_value = match pv.param_type {
1168            JobParameterType::Path => match &pv.value {
1169                openjd_expr::ExprValue::String(s) => openjd_expr::ExprValue::String(s.clone()),
1170                openjd_expr::ExprValue::Path { value, .. } => {
1171                    openjd_expr::ExprValue::String(value.clone())
1172                }
1173                _ => pv.value.clone(),
1174            },
1175            JobParameterType::ListPath => {
1176                if let openjd_expr::ExprValue::ListString(ref elements, _) = pv.value {
1177                    openjd_expr::ExprValue::ListString(elements.clone(), 0)
1178                } else if let openjd_expr::ExprValue::ListPath(ref elements, _, _) = pv.value {
1179                    openjd_expr::ExprValue::ListString(elements.clone(), 0)
1180                } else {
1181                    pv.value.clone()
1182                }
1183            }
1184            _ => pv.value.clone(),
1185        };
1186        symtab.set(&format!("RawParam.{name}"), raw_value)?;
1187    }
1188    Ok(symtab)
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193    use super::*;
1194    use openjd_expr::path_mapping::PathFormat;
1195
1196    #[test]
1197    fn normalize_unc_dotdot_preserves_server_share() {
1198        // \\server\share\.. should not pop below the server\share components
1199        let result = normalize_path_str(r"\\server\share\..", PathFormat::Windows);
1200        assert_eq!(
1201            result, r"\\server\share",
1202            "UNC path should preserve server\\share: got {result}"
1203        );
1204    }
1205
1206    #[test]
1207    fn normalize_unc_double_dotdot_preserves_server_share() {
1208        let result = normalize_path_str(r"\\server\share\a\..\..", PathFormat::Windows);
1209        assert_eq!(result, r"\\server\share", "got {result}");
1210    }
1211
1212    #[test]
1213    fn normalize_unc_excessive_dotdot_preserves_server_share() {
1214        // More .. than components should clamp at server\share
1215        let result = normalize_path_str(r"\\server\share\..\..\..\..", PathFormat::Windows);
1216        assert_eq!(result, r"\\server\share", "got {result}");
1217    }
1218
1219    #[test]
1220    fn coerce_int_to_float_returns_ok() {
1221        let val = openjd_expr::ExprValue::Int(42);
1222        let result = coerce_to_type(&val, JobParameterType::Float);
1223        assert!(result.is_ok(), "int-to-float coercion should succeed");
1224        match result.unwrap() {
1225            openjd_expr::ExprValue::Float(f) => assert_eq!(f.value(), 42.0),
1226            other => panic!("expected Float, got {other:?}"),
1227        }
1228    }
1229
1230    #[test]
1231    fn coerce_large_int_to_float_returns_ok() {
1232        // Large i64 that loses precision as f64 but is still finite
1233        let val = openjd_expr::ExprValue::Int(i64::MAX);
1234        let result = coerce_to_type(&val, JobParameterType::Float);
1235        assert!(result.is_ok(), "large int-to-float coercion should succeed");
1236    }
1237}