Skip to main content

wdl_engine/
inputs.rs

1//! Implementation of workflow and task inputs.
2
3use std::collections::BTreeSet;
4use std::collections::HashMap;
5use std::collections::HashSet;
6use std::fs::File;
7use std::io::BufReader;
8use std::path::Path;
9
10use anyhow::Context;
11use anyhow::Result;
12use anyhow::bail;
13use indexmap::IndexMap;
14use serde::Serialize;
15use serde::ser::SerializeMap;
16use serde_json::Value as JsonValue;
17use serde_yaml_ng::Value as YamlValue;
18use wdl_analysis::Document;
19use wdl_analysis::document::Input;
20use wdl_analysis::document::Task;
21use wdl_analysis::document::Workflow;
22use wdl_analysis::types::CallKind;
23use wdl_analysis::types::Coercible as _;
24use wdl_analysis::types::Optional;
25use wdl_analysis::types::PrimitiveType;
26use wdl_analysis::types::display_types;
27use wdl_analysis::types::v1::task_hint_types;
28use wdl_analysis::types::v1::task_requirement_types;
29use wdl_ast::SupportedVersion;
30use wdl_ast::version::V1;
31
32use crate::Coercible;
33use crate::EvaluationPath;
34use crate::Value;
35
36/// A type alias to a JSON map (object).
37pub type JsonMap = serde_json::Map<String, JsonValue>;
38
39/// Checks that an input value matches the type of the input.
40fn check_input_type(document: &Document, name: &str, input: &Input, value: &Value) -> Result<()> {
41    // For WDL 1.2, we accept optional values for the input even if the input's type
42    // is non-optional; if the runtime value is `None` for a non-optional input, the
43    // default expression will be evaluated instead
44    let expected_ty = if !input.required()
45        && document
46            .version()
47            .map(|v| v >= SupportedVersion::V1(V1::Two))
48            .unwrap_or(false)
49    {
50        input.ty().optional()
51    } else {
52        input.ty().clone()
53    };
54
55    let ty = value.ty();
56    if !ty.is_coercible_to(&expected_ty) {
57        bail!("expected type `{expected_ty}` for input `{name}`, but found `{ty}`");
58    }
59
60    Ok(())
61}
62
63/// Represents inputs to a task.
64#[derive(Default, Debug, Clone)]
65pub struct TaskInputs {
66    /// The task input values.
67    inputs: IndexMap<String, Value>,
68    /// The overridden requirements section values.
69    requirements: HashMap<String, Value>,
70    /// The overridden hints section values.
71    hints: HashMap<String, Value>,
72}
73
74impl TaskInputs {
75    /// Iterates the inputs to the task.
76    pub fn iter(&self) -> impl Iterator<Item = (&str, &Value)> + use<'_> {
77        self.inputs.iter().map(|(k, v)| (k.as_str(), v))
78    }
79
80    /// Determines if the inputs are empty.
81    pub fn is_empty(&self) -> bool {
82        self.len() == 0
83    }
84
85    /// Gets the length of the inputs.
86    ///
87    /// This includes the count of inputs, requirements, and hints.
88    pub fn len(&self) -> usize {
89        self.inputs.len() + self.requirements.len() + self.hints.len()
90    }
91
92    /// Gets an input by name.
93    pub fn get(&self, name: &str) -> Option<&Value> {
94        self.inputs.get(name)
95    }
96
97    /// Sets a task input.
98    ///
99    /// Returns the previous value, if any.
100    pub fn set(&mut self, name: impl Into<String>, value: impl Into<Value>) -> Option<Value> {
101        self.inputs.insert(name.into(), value.into())
102    }
103
104    /// Gets an overridden requirement by name.
105    pub fn requirement(&self, name: &str) -> Option<&Value> {
106        self.requirements.get(name)
107    }
108
109    /// Overrides a requirement by name.
110    pub fn override_requirement(&mut self, name: impl Into<String>, value: impl Into<Value>) {
111        self.requirements.insert(name.into(), value.into());
112    }
113
114    /// Gets an overridden hint by name.
115    pub fn hint(&self, name: &str) -> Option<&Value> {
116        self.hints.get(name)
117    }
118
119    /// Overrides a hint by name.
120    pub fn override_hint(&mut self, name: impl Into<String>, value: impl Into<Value>) {
121        self.hints.insert(name.into(), value.into());
122    }
123
124    /// Replaces any `File` or `Directory` input values with joining the
125    /// specified path with the value.
126    ///
127    /// This method will attempt to coerce matching input values to their
128    /// expected types.
129    pub async fn join_paths<'a>(
130        &mut self,
131        task: &Task,
132        path: impl Fn(&str) -> Result<&'a EvaluationPath>,
133    ) -> Result<()> {
134        for (name, value) in self.inputs.iter_mut() {
135            let Some(ty) = task.inputs().get(name).map(|input| input.ty().clone()) else {
136                bail!("could not find an expected type for input {name}");
137            };
138
139            let base_dir = path(name)?;
140
141            if let Ok(v) = value.coerce(None, &ty) {
142                *value = v
143                    .resolve_paths(ty.is_optional(), None, None, &|path| path.expand(base_dir))
144                    .await?;
145            }
146        }
147        Ok(())
148    }
149
150    /// Validates the inputs for the given task.
151    ///
152    /// The `specified` set of inputs are those that are present, but may not
153    /// have values available at validation.
154    pub fn validate(
155        &self,
156        document: &Document,
157        task: &Task,
158        specified: Option<&HashSet<String>>,
159    ) -> Result<()> {
160        let version = document.version().context("missing document version")?;
161
162        // Start by validating all the specified inputs and their types
163        for (name, value) in &self.inputs {
164            let input = task
165                .inputs()
166                .get(name)
167                .with_context(|| format!("unknown input `{name}`"))?;
168
169            check_input_type(document, name, input, value)?;
170        }
171
172        // Next check for missing required inputs
173        for (name, input) in task.inputs() {
174            if input.required()
175                && !self.inputs.contains_key(name)
176                && specified.map(|s| !s.contains(name)).unwrap_or(true)
177            {
178                bail!(
179                    "missing required input `{name}` to task `{task}`",
180                    task = task.name()
181                );
182            }
183        }
184
185        // Check the types of the specified requirements
186        for (name, value) in &self.requirements {
187            let ty = value.ty();
188            if let Some(expected) = task_requirement_types(version, name.as_str()) {
189                if !expected.iter().any(|target| ty.is_coercible_to(target)) {
190                    bail!(
191                        "expected {expected} for requirement `{name}`, but found type `{ty}`",
192                        expected = display_types(expected),
193                    );
194                }
195
196                continue;
197            }
198
199            bail!("unsupported requirement `{name}`");
200        }
201
202        // Check the types of the specified hints
203        for (name, value) in &self.hints {
204            let ty = value.ty();
205            if let Some(expected) = task_hint_types(version, name.as_str(), false)
206                && !expected.iter().any(|target| ty.is_coercible_to(target))
207            {
208                bail!(
209                    "expected {expected} for hint `{name}`, but found type `{ty}`",
210                    expected = display_types(expected),
211                );
212            }
213        }
214
215        Ok(())
216    }
217
218    /// Sets a value with dotted path notation.
219    ///
220    /// If the provided `value` is a [`PrimitiveType`] other than
221    /// [`PrimitiveType::String`] and the `path` is to an input which is of
222    /// type [`PrimitiveType::String`], `value` will be converted to a string
223    /// and accepted as valid.
224    ///
225    /// Returns `true` if the given path was for an input or `false` if the
226    /// given path was for a requirement or hint.
227    fn set_path_value(
228        &mut self,
229        document: &Document,
230        task: &Task,
231        path: &str,
232        value: Value,
233    ) -> Result<bool> {
234        let version = document.version().expect("document should have a version");
235
236        match path.split_once('.') {
237            // The path might contain a requirement or hint
238            Some((key, remainder)) => {
239                let (must_match, matched) = match key {
240                    "runtime" => (
241                        false,
242                        task_requirement_types(version, remainder)
243                            .map(|types| (true, types))
244                            .or_else(|| {
245                                task_hint_types(version, remainder, false)
246                                    .map(|types| (false, types))
247                            }),
248                    ),
249                    "requirements" => (
250                        true,
251                        task_requirement_types(version, remainder).map(|types| (true, types)),
252                    ),
253                    "hints" => (
254                        false,
255                        task_hint_types(version, remainder, false).map(|types| (false, types)),
256                    ),
257                    _ => {
258                        bail!(
259                            "task `{task}` does not have an input named `{path}`",
260                            task = task.name()
261                        );
262                    }
263                };
264
265                if let Some((requirement, expected)) = matched {
266                    for ty in expected {
267                        if value.ty().is_coercible_to(ty) {
268                            if requirement {
269                                self.requirements.insert(remainder.to_string(), value);
270                            } else {
271                                self.hints.insert(remainder.to_string(), value);
272                            }
273                            return Ok(false);
274                        }
275                    }
276
277                    bail!(
278                        "expected {expected} for {key} key `{remainder}`, but found type `{ty}`",
279                        expected = display_types(expected),
280                        ty = value.ty()
281                    );
282                } else if must_match {
283                    bail!("unsupported {key} key `{remainder}`");
284                } else {
285                    Ok(false)
286                }
287            }
288            // The path is to an input
289            None => {
290                let input = task.inputs().get(path).with_context(|| {
291                    format!(
292                        "task `{name}` does not have an input named `{path}`",
293                        name = task.name()
294                    )
295                })?;
296
297                // Allow primitive values to implicitly convert to string
298                let actual = value.ty();
299                let expected = input.ty();
300                if let Some(PrimitiveType::String) = expected.as_primitive()
301                    && let Some(actual) = actual.as_primitive()
302                    && actual != PrimitiveType::String
303                {
304                    self.inputs
305                        .insert(path.to_string(), value.to_string().into());
306                    return Ok(true);
307                }
308
309                check_input_type(document, path, input, &value)?;
310                self.inputs.insert(path.to_string(), value);
311                Ok(true)
312            }
313        }
314    }
315}
316
317impl<S, V> FromIterator<(S, V)> for TaskInputs
318where
319    S: Into<String>,
320    V: Into<Value>,
321{
322    fn from_iter<T: IntoIterator<Item = (S, V)>>(iter: T) -> Self {
323        Self {
324            inputs: iter
325                .into_iter()
326                .map(|(k, v)| (k.into(), v.into()))
327                .collect(),
328            requirements: Default::default(),
329            hints: Default::default(),
330        }
331    }
332}
333
334impl Serialize for TaskInputs {
335    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
336    where
337        S: serde::Serializer,
338    {
339        let mut map = serializer.serialize_map(Some(self.len()))?;
340
341        for (k, v) in &self.inputs {
342            let v = crate::ValueSerializer::new(None, v, true);
343            map.serialize_entry(k, &v)?;
344        }
345
346        for (k, v) in &self.requirements {
347            let v = crate::ValueSerializer::new(None, v, true);
348            map.serialize_entry(&format!("requirements.{k}"), &v)?;
349        }
350
351        for (k, v) in &self.hints {
352            let v = crate::ValueSerializer::new(None, v, true);
353            map.serialize_entry(&format!("hints.{k}"), &v)?;
354        }
355
356        map.end()
357    }
358}
359
360/// Represents inputs to a workflow.
361#[derive(Default, Debug, Clone)]
362pub struct WorkflowInputs {
363    /// The workflow input values.
364    inputs: IndexMap<String, Value>,
365    /// The nested call inputs.
366    calls: HashMap<String, Inputs>,
367}
368
369impl WorkflowInputs {
370    /// Determines if there are any nested inputs in the workflow inputs.
371    ///
372    /// Returns `true` if the inputs contains nested inputs or `false` if it
373    /// does not.
374    pub fn has_nested_inputs(&self) -> bool {
375        self.calls.values().any(|inputs| match inputs {
376            Inputs::Task(task) => !task.inputs.is_empty(),
377            Inputs::Workflow(workflow) => workflow.has_nested_inputs(),
378        })
379    }
380
381    /// Iterates the inputs to the workflow.
382    pub fn iter(&self) -> impl Iterator<Item = (&str, &Value)> + use<'_> {
383        self.inputs.iter().map(|(k, v)| (k.as_str(), v))
384    }
385
386    /// Determines if the inputs are empty.
387    pub fn is_empty(&self) -> bool {
388        self.len() == 0
389    }
390
391    /// Gets the length of the workflow inputs.
392    ///
393    /// This includes the workflow inputs plus the lengths of all nested inputs.
394    pub fn len(&self) -> usize {
395        self.inputs.len() + self.calls.values().map(Inputs::len).sum::<usize>()
396    }
397
398    /// Gets an input by name.
399    pub fn get(&self, name: &str) -> Option<&Value> {
400        self.inputs.get(name)
401    }
402
403    /// Gets the nested call inputs.
404    pub fn calls(&self) -> &HashMap<String, Inputs> {
405        &self.calls
406    }
407
408    /// Gets the nested call inputs.
409    pub fn calls_mut(&mut self) -> &mut HashMap<String, Inputs> {
410        &mut self.calls
411    }
412
413    /// Sets a workflow input.
414    ///
415    /// Returns the previous value, if any.
416    pub fn set(&mut self, name: impl Into<String>, value: impl Into<Value>) -> Option<Value> {
417        self.inputs.insert(name.into(), value.into())
418    }
419
420    /// Checks if the inputs contain a value with the specified name.
421    ///
422    /// This does not check nested call inputs.
423    pub fn contains(&self, name: &str) -> bool {
424        self.inputs.contains_key(name)
425    }
426
427    /// Replaces any `File` or `Directory` input values with joining the
428    /// specified path with the value.
429    ///
430    /// This method will attempt to coerce matching input values to their
431    /// expected types.
432    pub async fn join_paths<'a>(
433        &mut self,
434        workflow: &Workflow,
435        path: impl Fn(&str) -> Result<&'a EvaluationPath>,
436    ) -> Result<()> {
437        for (name, value) in self.inputs.iter_mut() {
438            let Some(ty) = workflow.inputs().get(name).map(|input| input.ty().clone()) else {
439                bail!("could not find an expected type for input {name}");
440            };
441
442            let base_dir = path(name)?;
443
444            if let Ok(v) = value.coerce(None, &ty) {
445                *value = v
446                    .resolve_paths(ty.is_optional(), None, None, &|path| path.expand(base_dir))
447                    .await?;
448            }
449        }
450        Ok(())
451    }
452
453    /// Validates the inputs for the given workflow.
454    ///
455    /// The `specified` set of inputs are those that are present, but may not
456    /// have values available at validation.
457    pub fn validate(
458        &self,
459        document: &Document,
460        workflow: &Workflow,
461        specified: Option<&HashSet<String>>,
462    ) -> Result<()> {
463        // Start by validating all the specified inputs and their types
464        for (name, value) in &self.inputs {
465            let input = workflow
466                .inputs()
467                .get(name)
468                .with_context(|| format!("unknown input `{name}`"))?;
469            check_input_type(document, name, input, value)?;
470        }
471
472        // Next check for missing required inputs
473        for (name, input) in workflow.inputs() {
474            if input.required()
475                && !self.inputs.contains_key(name)
476                && specified.map(|s| !s.contains(name)).unwrap_or(true)
477            {
478                bail!(
479                    "missing required input `{name}` to workflow `{workflow}`",
480                    workflow = workflow.name()
481                );
482            }
483        }
484
485        // Check that the workflow allows nested inputs
486        if !workflow.allows_nested_inputs() && self.has_nested_inputs() {
487            bail!(
488                "cannot specify a nested call input for workflow `{name}` as it does not allow \
489                 nested inputs",
490                name = workflow.name()
491            );
492        }
493
494        // Check the inputs to the specified calls
495        for (name, inputs) in &self.calls {
496            let call = workflow.calls().get(name).with_context(|| {
497                format!(
498                    "workflow `{workflow}` does not have a call named `{name}`",
499                    workflow = workflow.name()
500                )
501            })?;
502
503            // Resolve the target document; the namespace is guaranteed to be present in the
504            // document.
505            let document = call
506                .namespace()
507                .map(|ns| {
508                    document
509                        .namespace(ns)
510                        .expect("namespace should be present")
511                        .document()
512                })
513                .unwrap_or(document);
514
515            // Validate the call's inputs
516            let inputs = match call.kind() {
517                CallKind::Task => {
518                    let task = document
519                        .task_by_name(call.name())
520                        .expect("task should be present");
521
522                    let task_inputs = inputs.as_task_inputs().with_context(|| {
523                        format!("`{name}` is a call to a task, but workflow inputs were supplied")
524                    })?;
525
526                    task_inputs.validate(document, task, Some(call.specified()))?;
527                    &task_inputs.inputs
528                }
529                CallKind::Workflow => {
530                    let workflow = document.workflow().expect("should have a workflow");
531                    assert_eq!(
532                        workflow.name(),
533                        call.name(),
534                        "call name does not match workflow name"
535                    );
536                    let workflow_inputs = inputs.as_workflow_inputs().with_context(|| {
537                        format!("`{name}` is a call to a workflow, but task inputs were supplied")
538                    })?;
539
540                    workflow_inputs.validate(document, workflow, Some(call.specified()))?;
541                    &workflow_inputs.inputs
542                }
543            };
544
545            for input in inputs.keys() {
546                if call.specified().contains(input) {
547                    bail!(
548                        "cannot specify nested input `{input}` for call `{call}` as it was \
549                         explicitly specified in the call itself",
550                        call = call.name(),
551                    );
552                }
553            }
554        }
555
556        // Finally, check for missing call arguments
557        if workflow.allows_nested_inputs() {
558            for (call, ty) in workflow.calls() {
559                let inputs = self.calls.get(call);
560
561                for (input, _) in ty
562                    .inputs()
563                    .iter()
564                    .filter(|(n, i)| i.required() && !ty.specified().contains(*n))
565                {
566                    if !inputs.map(|i| i.get(input).is_some()).unwrap_or(false) {
567                        bail!("missing required input `{input}` for call `{call}`");
568                    }
569                }
570            }
571        }
572
573        Ok(())
574    }
575
576    /// Sets a value with dotted path notation.
577    ///
578    /// If the provided `value` is a [`PrimitiveType`] other than
579    /// [`PrimitiveType::String`] and the `path` is to an input which is of
580    /// type [`PrimitiveType::String`], `value` will be converted to a string
581    /// and accepted as valid.
582    ///
583    /// Returns `true` if the path was to an input or `false` if it was not.
584    fn set_path_value(
585        &mut self,
586        document: &Document,
587        workflow: &Workflow,
588        path: &str,
589        value: Value,
590    ) -> Result<bool> {
591        match path.split_once('.') {
592            Some((name, remainder)) => {
593                // Resolve the call by name
594                let call = workflow.calls().get(name).with_context(|| {
595                    format!(
596                        "workflow `{workflow}` does not have a call named `{name}`",
597                        workflow = workflow.name()
598                    )
599                })?;
600
601                // Insert the inputs for the call
602                let inputs =
603                    self.calls
604                        .entry(name.to_string())
605                        .or_insert_with(|| match call.kind() {
606                            CallKind::Task => Inputs::Task(Default::default()),
607                            CallKind::Workflow => Inputs::Workflow(Default::default()),
608                        });
609
610                // Resolve the target document; the namespace is guaranteed to be present in the
611                // document.
612                let document = call
613                    .namespace()
614                    .map(|ns| {
615                        document
616                            .namespace(ns)
617                            .expect("namespace should be present")
618                            .document()
619                    })
620                    .unwrap_or(document);
621
622                let next = remainder
623                    .split_once('.')
624                    .map(|(n, _)| n)
625                    .unwrap_or(remainder);
626                if call.specified().contains(next) {
627                    bail!(
628                        "cannot specify nested input `{next}` for call `{name}` as it was \
629                         explicitly specified in the call itself",
630                    );
631                }
632
633                // Recurse on the call's inputs to set the value
634                let input = match call.kind() {
635                    CallKind::Task => {
636                        let task = document
637                            .task_by_name(call.name())
638                            .expect("task should be present");
639                        inputs
640                            .as_task_inputs_mut()
641                            .expect("should be a task input")
642                            .set_path_value(document, task, remainder, value)?
643                    }
644                    CallKind::Workflow => {
645                        let workflow = document.workflow().expect("should have a workflow");
646                        assert_eq!(
647                            workflow.name(),
648                            call.name(),
649                            "call name does not match workflow name"
650                        );
651                        inputs
652                            .as_workflow_inputs_mut()
653                            .expect("should be a task input")
654                            .set_path_value(document, workflow, remainder, value)?
655                    }
656                };
657
658                if input && !workflow.allows_nested_inputs() {
659                    bail!(
660                        "cannot specify a nested call input for workflow `{workflow}` as it does \
661                         not allow nested inputs",
662                        workflow = workflow.name()
663                    );
664                }
665
666                Ok(input)
667            }
668            None => {
669                let input = workflow.inputs().get(path).with_context(|| {
670                    format!(
671                        "workflow `{workflow}` does not have an input named `{path}`",
672                        workflow = workflow.name()
673                    )
674                })?;
675
676                // Allow primitive values to implicitly convert to string
677                let actual = value.ty();
678                let expected = input.ty();
679                if let Some(PrimitiveType::String) = expected.as_primitive()
680                    && let Some(actual) = actual.as_primitive()
681                    && actual != PrimitiveType::String
682                {
683                    self.inputs
684                        .insert(path.to_string(), value.to_string().into());
685                    return Ok(true);
686                }
687
688                check_input_type(document, path, input, &value)?;
689                self.inputs.insert(path.to_string(), value);
690                Ok(true)
691            }
692        }
693    }
694}
695
696impl<S, V> FromIterator<(S, V)> for WorkflowInputs
697where
698    S: Into<String>,
699    V: Into<Value>,
700{
701    fn from_iter<T: IntoIterator<Item = (S, V)>>(iter: T) -> Self {
702        Self {
703            inputs: iter
704                .into_iter()
705                .map(|(k, v)| (k.into(), v.into()))
706                .collect(),
707            calls: Default::default(),
708        }
709    }
710}
711
712impl Serialize for WorkflowInputs {
713    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
714    where
715        S: serde::Serializer,
716    {
717        let mut map = serializer.serialize_map(Some(self.len()))?;
718        for (k, v) in &self.inputs {
719            let serialized_value = crate::ValueSerializer::new(None, v, true);
720            map.serialize_entry(k, &serialized_value)?;
721        }
722
723        for (k, v) in &self.calls {
724            let serialized = serde_json::to_value(v).map_err(|_| {
725                serde::ser::Error::custom(format!("failed to serialize inputs for call `{k}`"))
726            })?;
727            let mut map = serde_json::Map::new();
728            if let JsonValue::Object(obj) = serialized {
729                for (inner, value) in obj {
730                    map.insert(format!("{k}.{inner}"), value);
731                }
732            }
733        }
734
735        map.end()
736    }
737}
738
739/// Represents inputs to a WDL workflow or task.
740#[derive(Debug, Clone)]
741pub enum Inputs {
742    /// The inputs are to a task.
743    Task(TaskInputs),
744    /// The inputs are to a workflow.
745    Workflow(WorkflowInputs),
746}
747
748impl Inputs {
749    /// Parses an inputs file from the given file path.
750    ///
751    /// The format (JSON or YAML) is determined by the file extension:
752    ///
753    /// - `.json` for JSON format
754    /// - `.yml` or `.yaml` for YAML format
755    ///
756    /// The parse uses the provided document to validate the input keys within
757    /// the file.
758    ///
759    /// Returns `Ok(Some(_))` if the inputs are not empty.
760    ///
761    /// Returns `Ok(None)` if the inputs are empty.
762    pub fn parse(document: &Document, path: impl AsRef<Path>) -> Result<Option<(String, Self)>> {
763        let path = path.as_ref();
764
765        match path.extension().and_then(|ext| ext.to_str()) {
766            Some("json") => Self::parse_json(document, path),
767            Some("yml") | Some("yaml") => Self::parse_yaml(document, path),
768            ext => bail!(
769                "unsupported file extension: `{ext}`; the supported formats are JSON (`.json`) \
770                 and YAML (`.yaml` and `.yml`)",
771                ext = ext.unwrap_or("")
772            ),
773        }
774        .with_context(|| format!("failed to parse input file `{path}`", path = path.display()))
775    }
776
777    /// Parses a JSON inputs file from the given file path.
778    ///
779    /// The parse uses the provided document to validate the input keys within
780    /// the file.
781    ///
782    /// Returns `Ok(Some(_))` if the inputs are not empty.
783    ///
784    /// Returns `Ok(None)` if the inputs are empty.
785    pub fn parse_json(
786        document: &Document,
787        path: impl AsRef<Path>,
788    ) -> Result<Option<(String, Self)>> {
789        let path = path.as_ref();
790
791        let file = File::open(path).with_context(|| {
792            format!("failed to open input file `{path}`", path = path.display())
793        })?;
794
795        // Parse the JSON (should be an object)
796        let reader = BufReader::new(file);
797
798        let map = std::mem::take(
799            serde_json::from_reader::<_, JsonValue>(reader)?
800                .as_object_mut()
801                .with_context(|| {
802                    format!(
803                        "expected input file `{path}` to contain a JSON object",
804                        path = path.display()
805                    )
806                })?,
807        );
808
809        Self::parse_json_object(document, map)
810    }
811
812    /// Parses a YAML inputs file from the given file path.
813    ///
814    /// The parse uses the provided document to validate the input keys within
815    /// the file.
816    ///
817    /// Returns `Ok(Some(_))` if the inputs are not empty.
818    ///
819    /// Returns `Ok(None)` if the inputs are empty.
820    pub fn parse_yaml(
821        document: &Document,
822        path: impl AsRef<Path>,
823    ) -> Result<Option<(String, Self)>> {
824        let path = path.as_ref();
825
826        let file = File::open(path).with_context(|| {
827            format!("failed to open input file `{path}`", path = path.display())
828        })?;
829
830        // Parse the YAML
831        let reader = BufReader::new(file);
832        let yaml = serde_yaml_ng::from_reader::<_, YamlValue>(reader)?;
833
834        // Convert YAML to JSON format
835        let mut json = serde_json::to_value(yaml).with_context(|| {
836            format!(
837                "failed to convert YAML to JSON for processing `{path}`",
838                path = path.display()
839            )
840        })?;
841
842        let object = std::mem::take(json.as_object_mut().with_context(|| {
843            format!(
844                "expected input file `{path}` to contain a YAML mapping",
845                path = path.display()
846            )
847        })?);
848
849        Self::parse_json_object(document, object)
850    }
851
852    /// Determines if the inputs are empty.
853    pub fn is_empty(&self) -> bool {
854        self.len() == 0
855    }
856
857    /// Gets the length of all inputs.
858    ///
859    /// For task inputs, this include the inputs, requirements, and hints.
860    ///
861    /// For workflow inputs, this includes the inputs and nested inputs.
862    pub fn len(&self) -> usize {
863        match self {
864            Self::Task(inputs) => inputs.len(),
865            Self::Workflow(inputs) => inputs.len(),
866        }
867    }
868
869    /// Gets an input value.
870    pub fn get(&self, name: &str) -> Option<&Value> {
871        match self {
872            Self::Task(t) => t.inputs.get(name),
873            Self::Workflow(w) => w.inputs.get(name),
874        }
875    }
876
877    /// Sets an input value.
878    ///
879    /// Returns the previous value, if any.
880    pub fn set(&mut self, name: impl Into<String>, value: impl Into<Value>) -> Option<Value> {
881        match self {
882            Self::Task(inputs) => inputs.set(name, value),
883            Self::Workflow(inputs) => inputs.set(name, value),
884        }
885    }
886
887    /// Gets the task inputs.
888    ///
889    /// Returns `None` if the inputs are for a workflow.
890    pub fn as_task_inputs(&self) -> Option<&TaskInputs> {
891        match self {
892            Self::Task(inputs) => Some(inputs),
893            Self::Workflow(_) => None,
894        }
895    }
896
897    /// Gets a mutable reference to task inputs.
898    ///
899    /// Returns `None` if the inputs are for a workflow.
900    pub fn as_task_inputs_mut(&mut self) -> Option<&mut TaskInputs> {
901        match self {
902            Self::Task(inputs) => Some(inputs),
903            Self::Workflow(_) => None,
904        }
905    }
906
907    /// Unwraps the inputs as task inputs.
908    ///
909    /// # Panics
910    ///
911    /// Panics if the inputs are for a workflow.
912    pub fn unwrap_task_inputs(self) -> TaskInputs {
913        match self {
914            Self::Task(inputs) => inputs,
915            Self::Workflow(_) => panic!("inputs are for a workflow"),
916        }
917    }
918
919    /// Gets the workflow inputs.
920    ///
921    /// Returns `None` if the inputs are for a task.
922    pub fn as_workflow_inputs(&self) -> Option<&WorkflowInputs> {
923        match self {
924            Self::Task(_) => None,
925            Self::Workflow(inputs) => Some(inputs),
926        }
927    }
928
929    /// Gets a mutable reference to workflow inputs.
930    ///
931    /// Returns `None` if the inputs are for a task.
932    pub fn as_workflow_inputs_mut(&mut self) -> Option<&mut WorkflowInputs> {
933        match self {
934            Self::Task(_) => None,
935            Self::Workflow(inputs) => Some(inputs),
936        }
937    }
938
939    /// Unwraps the inputs as workflow inputs.
940    ///
941    /// # Panics
942    ///
943    /// Panics if the inputs are for a task.
944    pub fn unwrap_workflow_inputs(self) -> WorkflowInputs {
945        match self {
946            Self::Task(_) => panic!("inputs are for a task"),
947            Self::Workflow(inputs) => inputs,
948        }
949    }
950
951    /// Parses the root object in a [`JsonMap`].
952    ///
953    /// Returns `Ok(Some(_))` if the inputs are not empty.
954    ///
955    /// Returns `Ok(None)` if the inputs are empty.
956    pub fn parse_json_object(
957        document: &Document,
958        object: JsonMap,
959    ) -> Result<Option<(String, Self)>> {
960        // If the object is empty, treat it as an invocation without any inputs.
961        if object.is_empty() {
962            return Ok(None);
963        }
964
965        // Otherwise, build a set of candidate targets from the prefixes of each input
966        // key.
967        let mut target_candidates = BTreeSet::new();
968        for key in object.keys() {
969            let Some((prefix, _)) = key.split_once('.') else {
970                bail!(
971                    "invalid input key `{key}`: expected the key to be prefixed with the workflow \
972                     or task name",
973                )
974            };
975            target_candidates.insert(prefix);
976        }
977
978        // If every prefix is the same, there will be only one candidate. If not, report
979        // an error.
980        let target_name = match target_candidates
981            .iter()
982            .take(2)
983            .collect::<Vec<_>>()
984            .as_slice()
985        {
986            [] => panic!("no target candidates for inputs; report this as a bug"),
987            [target_name] => target_name.to_string(),
988            _ => bail!(
989                "invalid inputs: expected each input key to be prefixed with the same workflow or \
990                 task name, but found multiple prefixes: {target_candidates:?}",
991            ),
992        };
993
994        let inputs = match (document.task_by_name(&target_name), document.workflow()) {
995            (Some(task), _) => Self::parse_task_inputs(document, task, object)?,
996            (None, Some(workflow)) if workflow.name() == target_name => {
997                Self::parse_workflow_inputs(document, workflow, object)?
998            }
999            _ => bail!(
1000                "invalid inputs: a task or workflow named `{target_name}` does not exist in the \
1001                 document"
1002            ),
1003        };
1004        Ok(Some((target_name, inputs)))
1005    }
1006
1007    /// Parses the inputs for a task.
1008    fn parse_task_inputs(document: &Document, task: &Task, object: JsonMap) -> Result<Self> {
1009        let mut inputs = TaskInputs::default();
1010        for (key, value) in object {
1011            // Convert from serde_json::Value to crate::Value
1012            let value = serde_json::from_value(value)
1013                .with_context(|| format!("invalid input value for key `{key}`"))?;
1014
1015            match key.split_once(".") {
1016                Some((prefix, remainder)) if prefix == task.name() => {
1017                    inputs
1018                        .set_path_value(document, task, remainder, value)
1019                        .with_context(|| format!("invalid input key `{key}`"))?;
1020                }
1021                _ => {
1022                    // This should be caught by the initial check of the prefixes in
1023                    // `parse_json_object()`, but we retain a friendly error message in case this
1024                    // function gets called from another context in the future.
1025                    bail!(
1026                        "invalid input key `{key}`: expected key to be prefixed with `{task}`",
1027                        task = task.name()
1028                    );
1029                }
1030            }
1031        }
1032
1033        Ok(Inputs::Task(inputs))
1034    }
1035
1036    /// Parses the inputs for a workflow.
1037    fn parse_workflow_inputs(
1038        document: &Document,
1039        workflow: &Workflow,
1040        object: JsonMap,
1041    ) -> Result<Self> {
1042        let mut inputs = WorkflowInputs::default();
1043        for (key, value) in object {
1044            // Convert from serde_json::Value to crate::Value
1045            let value = serde_json::from_value(value)
1046                .with_context(|| format!("invalid input value for key `{key}`"))?;
1047
1048            match key.split_once(".") {
1049                Some((prefix, remainder)) if prefix == workflow.name() => {
1050                    inputs
1051                        .set_path_value(document, workflow, remainder, value)
1052                        .with_context(|| format!("invalid input key `{key}`"))?;
1053                }
1054                _ => {
1055                    // This should be caught by the initial check of the prefixes in
1056                    // `parse_json_object()`, but we retain a friendly error message in case this
1057                    // function gets called from another context in the future.
1058                    bail!(
1059                        "invalid input key `{key}`: expected key to be prefixed with `{workflow}`",
1060                        workflow = workflow.name()
1061                    );
1062                }
1063            }
1064        }
1065
1066        Ok(Inputs::Workflow(inputs))
1067    }
1068}
1069
1070impl From<TaskInputs> for Inputs {
1071    fn from(inputs: TaskInputs) -> Self {
1072        Self::Task(inputs)
1073    }
1074}
1075
1076impl From<WorkflowInputs> for Inputs {
1077    fn from(inputs: WorkflowInputs) -> Self {
1078        Self::Workflow(inputs)
1079    }
1080}
1081
1082impl Serialize for Inputs {
1083    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1084    where
1085        S: serde::Serializer,
1086    {
1087        match self {
1088            Self::Task(inputs) => inputs.serialize(serializer),
1089            Self::Workflow(inputs) => inputs.serialize(serializer),
1090        }
1091    }
1092}