1use 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
20pub 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 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 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#[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 pub source: String,
150 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 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 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 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 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 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 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 _ => {} }
657 Ok(())
658 }
659}
660
661pub(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
710pub(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 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
785pub struct PathParameterOptions<'a> {
787 pub job_template_dir: &'a str,
789 pub current_working_dir: &'a str,
791 pub path_format: openjd_expr::path_mapping::PathFormat,
795 pub allow_template_dir_walk_up: bool,
798 pub allow_uri_path_values: bool,
802}
803
804impl<'a> PathParameterOptions<'a> {
805 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
817pub 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(¶m.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 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 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) = ¶m.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 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
1023fn 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
1037fn is_absolute_for_format(s: &str, format: openjd_expr::path_mapping::PathFormat) -> bool {
1043 openjd_expr::functions::path::is_absolute(s, format)
1044}
1045
1046fn 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
1056fn 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
1065fn 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 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 let root = format!("{}:{sep}", path.chars().next().unwrap());
1093 (root, &path[3..], 0)
1094 } else if path.starts_with("\\\\") || path.starts_with("//") {
1095 (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
1118pub(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
1153pub fn build_symbol_table(params: &JobParameterValues) -> Result<SymbolTable, ModelError> {
1155 let mut symtab = SymbolTable::new();
1156 for (name, pv) in params {
1157 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 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 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 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}