Skip to main content

moon_config/shapes/
output.rs

1use super::portable_path::{FilePath, GlobPath, PortablePath, is_glob_like};
2use super::*;
3use crate::{config_struct, generate_io_file_methods, generate_io_glob_methods, patterns};
4use deserialize_untagged_verbose_error::DeserializeUntaggedVerboseError;
5use moon_common::path::{
6    RelativeFrom, WorkspaceRelativePathBuf, expand_to_workspace_relative, standardize_separators,
7};
8use schematic::{Config, ParseError, Schema, SchemaBuilder, Schematic, schema::UnionType};
9use serde::{Deserialize, Serialize, Serializer};
10use std::str::FromStr;
11
12config_struct!(
13    /// A file path output.
14    #[derive(Config)]
15    pub struct FileOutput {
16        /// The literal file path.
17        pub file: FilePath,
18
19        /// Mark the file as optional instead of failing with
20        /// an error after running a task and the output doesn't exist.
21        #[serde(default, skip_serializing_if = "Option::is_none")]
22        pub optional: Option<bool>,
23    }
24);
25
26generate_io_file_methods!(FileOutput);
27
28impl FileOutput {
29    pub fn from_uri(uri: Uri) -> Result<Self, ParseError> {
30        let mut output = Self {
31            file: FilePath::parse(&uri.path)?,
32            ..Default::default()
33        };
34
35        for (key, value) in uri.query {
36            match key.as_str() {
37                "optional" => {
38                    output.optional = Some(parse_bool_field(&key, &value)?);
39                }
40                _ => {
41                    return Err(ParseError::new(format!("unknown file field `{key}`")));
42                }
43            };
44        }
45
46        Ok(output)
47    }
48}
49
50config_struct!(
51    /// A glob pattern output.
52    #[derive(Config)]
53    pub struct GlobOutput {
54        /// The glob pattern.
55        pub glob: GlobPath,
56
57        /// Mark the file as optional instead of failing with
58        /// an error after running a task and the output doesn't exist.
59        #[serde(default, skip_serializing_if = "Option::is_none")]
60        pub optional: Option<bool>,
61    }
62);
63
64generate_io_glob_methods!(GlobOutput);
65
66impl GlobOutput {
67    pub fn from_uri(uri: Uri) -> Result<Self, ParseError> {
68        let mut output = Self {
69            glob: GlobPath::parse(uri.path.replace("__QM__", "?"))?,
70            optional: None,
71        };
72
73        for (key, value) in uri.query {
74            match key.as_str() {
75                "optional" => {
76                    output.optional = Some(parse_bool_field(&key, &value)?);
77                }
78                _ => {
79                    return Err(ParseError::new(format!("unknown glob field `{key}`")));
80                }
81            };
82        }
83
84        Ok(output)
85    }
86}
87
88/// The different patterns a task output can be defined as.
89#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
90#[serde(try_from = "OutputShape")]
91pub enum Output {
92    File(FileOutput),
93    Glob(GlobOutput),
94    // Old
95    TokenFunc(String),
96    TokenVar(String),
97}
98
99impl Output {
100    pub fn create_uri(value: &str) -> Result<Uri, ParseError> {
101        // Always use forward slashes
102        let mut value = standardize_separators(value);
103
104        // Convert literal paths to a URI
105        if !value.contains("://") {
106            if is_glob_like(&value) {
107                value = format!("glob://{}", value.replace("?", "__QM__"));
108            } else {
109                value = format!("file://{value}");
110            }
111        }
112
113        Uri::parse(&value)
114    }
115
116    pub fn parse(value: impl AsRef<str>) -> Result<Self, ParseError> {
117        Self::from_str(value.as_ref())
118    }
119
120    pub fn as_str(&self) -> &str {
121        match self {
122            Self::TokenFunc(value) | Self::TokenVar(value) => value,
123            Self::File(value) => value.file.as_str(),
124            Self::Glob(value) => value.glob.as_str(),
125        }
126    }
127
128    pub fn is_glob(&self) -> bool {
129        matches!(self, Self::Glob(_))
130    }
131
132    pub fn is_optional(&self) -> bool {
133        match self {
134            Output::File(value) => value.optional.unwrap_or_default(),
135            Output::Glob(value) => value.optional.unwrap_or_default(),
136            _ => false,
137        }
138    }
139}
140
141impl FromStr for Output {
142    type Err = ParseError;
143
144    fn from_str(value: &str) -> Result<Self, Self::Err> {
145        // Token function
146        if value.starts_with('@') && patterns::TOKEN_FUNC_DISTINCT.is_match(value) {
147            return Ok(Self::TokenFunc(value.to_owned()));
148        }
149
150        // Token/environment variable
151        if value.starts_with('$') {
152            if patterns::ENV_VAR_DISTINCT.is_match(value) {
153                return Err(ParseError::new(
154                    "environment variable is not supported by itself",
155                ));
156            } else if patterns::ENV_VAR_GLOB_DISTINCT.is_match(value) {
157                return Err(ParseError::new(
158                    "environment variable globs are not supported",
159                ));
160            } else if patterns::TOKEN_VAR_DISTINCT.is_match(value) {
161                return Ok(Self::TokenVar(value.to_owned()));
162            }
163        }
164
165        // URI formats
166        let uri = Self::create_uri(value)?;
167
168        match uri.scheme.as_str() {
169            "file" => Ok(Self::File(FileOutput::from_uri(uri)?)),
170            "glob" => Ok(Self::Glob(GlobOutput::from_uri(uri)?)),
171            other => Err(ParseError::new(format!(
172                "output protocol `{other}://` is not supported"
173            ))),
174        }
175    }
176}
177
178impl Schematic for Output {
179    fn schema_name() -> Option<String> {
180        Some("Output".into())
181    }
182
183    fn build_schema(mut schema: SchemaBuilder) -> Schema {
184        schema.union(UnionType::new_any([
185            schema.infer::<String>(),
186            schema.infer::<FileOutput>(),
187            schema.infer::<GlobOutput>(),
188        ]))
189    }
190}
191
192impl Serialize for Output {
193    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
194    where
195        S: Serializer,
196    {
197        match self {
198            Output::TokenFunc(token) | Output::TokenVar(token) => serializer.serialize_str(token),
199            Output::File(output) => FileOutput::serialize(output, serializer),
200            Output::Glob(output) => GlobOutput::serialize(output, serializer),
201        }
202    }
203}
204
205#[derive(DeserializeUntaggedVerboseError)]
206enum OutputShape {
207    String(String),
208    // From most complex to least
209    File(FileOutput),
210    Glob(GlobOutput),
211}
212
213impl TryFrom<OutputShape> for Output {
214    type Error = ParseError;
215
216    fn try_from(base: OutputShape) -> Result<Self, Self::Error> {
217        match base {
218            OutputShape::String(output) => Self::parse(output),
219            OutputShape::File(output) => Ok(Self::File(output)),
220            OutputShape::Glob(output) => Ok(Self::Glob(output)),
221        }
222    }
223}