wdl_cli/inputs/
file.rs

1//! Input files parsed in from the command line.
2
3use std::path::Path;
4use std::path::PathBuf;
5use std::path::absolute;
6
7use serde_json::Value as JsonValue;
8use serde_yaml_ng::Value as YamlValue;
9use thiserror::Error;
10use wdl_engine::JsonMap;
11
12use crate::Inputs;
13
14/// An error related to a input file.
15#[derive(Error, Debug)]
16pub enum Error {
17    /// An error occurring in [`serde_json`].
18    #[error(transparent)]
19    Json(#[from] serde_json::Error),
20
21    /// An input file cannot be read from a directory.
22    #[error("an input file cannot be read from directory `{0}`")]
23    InvalidDir(PathBuf),
24
25    /// An I/O error.
26    #[error(transparent)]
27    Io(#[from] std::io::Error),
28
29    /// The input file did not contain a map at the root.
30    #[error("input file `{0}` did not contain a map from strings to values at the root")]
31    NonMapRoot(PathBuf),
32
33    /// Neither JSON nor YAML could be parsed from the provided path.
34    #[error(
35        "unsupported file extension `{0}`: the supported formats are JSON (`.json`) or YAML \
36         (`.yaml` and `.yml`)"
37    )]
38    UnsupportedFileExt(String),
39
40    /// An error occurring in [`serde_yaml_ng`].
41    #[error(transparent)]
42    Yaml(#[from] serde_yaml_ng::Error),
43}
44
45/// A [`Result`](std::result::Result) with an [`Error`].
46pub type Result<T> = std::result::Result<T, Error>;
47
48/// An input file containing WDL values.
49pub struct InputFile;
50
51impl InputFile {
52    /// Reads an input file.
53    ///
54    /// The file is attempted to be parsed based on its extension.
55    ///
56    /// - If the input file is successfully parsed, it's returned wrapped in
57    ///   [`Ok`].
58    /// - If a deserialization error is encountered while parsing the JSON/YAML
59    ///   file, an [`Error::Json`]/[`Error::Yaml`] is returned respectively.
60    /// - If no recognized extension is found, an [`Error::UnsupportedFileExt`]
61    ///   is returned.
62    pub fn read<P: AsRef<Path>>(path: P) -> Result<Inputs> {
63        fn map_to_inputs(map: JsonMap, origin: &Path) -> Inputs {
64            let mut inputs = Inputs::default();
65
66            for (key, value) in map.iter() {
67                inputs.insert(key.to_owned(), (origin.to_path_buf(), value.clone()));
68            }
69
70            inputs
71        }
72
73        let path = path.as_ref();
74
75        if path.is_dir() {
76            return Err(Error::InvalidDir(path.to_path_buf()));
77        }
78
79        let content: String = std::fs::read_to_string(path)?;
80
81        // Use the absolute path to the file's parent directory as the origin
82        // SAFETY: the check above ensures that the path is not a directory,
83        // which means that it can't be the root directory, which means that
84        // this call to `.parent()` cannot return `None`.
85        let origin = absolute(path)?;
86        let origin = origin.parent().unwrap();
87
88        match path.extension().and_then(|ext| ext.to_str()) {
89            Some("json") => serde_json::from_str::<JsonValue>(&content)
90                .map_err(Error::from)
91                .and_then(|value| match value {
92                    JsonValue::Object(object) => Ok(map_to_inputs(object, origin)),
93                    _ => Err(Error::NonMapRoot(path.to_path_buf())),
94                }),
95            Some("yml") | Some("yaml") => serde_yaml_ng::from_str::<YamlValue>(&content)
96                .map_err(Error::from)
97                .and_then(|value| match &value {
98                    YamlValue::Mapping(_) => {
99                        // SAFETY: a YAML mapping should always be able to be
100                        // transformed to a JSON value.
101                        let value = serde_json::to_value(value).unwrap();
102                        if let JsonValue::Object(map) = value {
103                            return Ok(map_to_inputs(map, origin));
104                        }
105
106                        // SAFETY: a serde map will always be translated to a
107                        // [`YamlValue::Mapping`] and a [`JsonValue::Object`],
108                        // so the above `if` statement should always evaluate to
109                        // `true`.
110                        unreachable!(
111                            "a YAML mapping must always coerce to a JSON object, found `{value}`"
112                        )
113                    }
114                    _ => Err(Error::NonMapRoot(path.to_path_buf())),
115                }),
116            ext => Err(Error::UnsupportedFileExt(ext.unwrap_or("").to_owned())),
117        }
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn nonmap_root() {
127        // A JSON file that does not have a map at the root.
128        let err = InputFile::read(Path::new("./tests/fixtures/nonmap_inputs.json")).unwrap_err();
129        assert!(matches!(
130            err,
131            Error::NonMapRoot(path) if path.to_str().unwrap() == "./tests/fixtures/nonmap_inputs.json"
132        ));
133
134        // A YML file that does not have a map at the root.
135        let err = InputFile::read(Path::new("./tests/fixtures/nonmap_inputs.yml")).unwrap_err();
136        assert!(matches!(
137            err,
138            Error::NonMapRoot(path) if path.to_str().unwrap() == "./tests/fixtures/nonmap_inputs.yml"
139        ));
140    }
141}