Skip to main content

moon_config/shapes/
input.rs

1use super::portable_path::{FilePath, GlobPath, PortablePath, is_glob_like};
2use super::*;
3use crate::{
4    DependencyScope, config_struct, config_unit_enum, generate_io_file_methods,
5    generate_io_glob_methods, patterns,
6};
7use deserialize_untagged_verbose_error::DeserializeUntaggedVerboseError;
8use moon_common::Id;
9use moon_common::path::{
10    RelativeFrom, WorkspaceRelativePathBuf, expand_to_workspace_relative, standardize_separators,
11};
12use schematic::{
13    Config, ConfigEnum, ParseError, RegexSetting, Schema, SchemaBuilder, Schematic,
14    schema::UnionType,
15};
16use serde::{Deserialize, Serialize, Serializer};
17use std::str::FromStr;
18
19config_struct!(
20    /// A file path input.
21    #[derive(Config)]
22    pub struct FileInput {
23        /// The literal file path.
24        pub file: FilePath,
25
26        /// Regex pattern to match the file's contents against
27        /// when determining affected status.
28        #[serde(
29            default,
30            alias = "match",
31            alias = "matches",
32            skip_serializing_if = "Option::is_none"
33        )]
34        pub content: Option<RegexSetting>,
35
36        /// Mark the file as optional instead of logging a warning
37        /// when hashing a task.
38        #[serde(default, skip_serializing_if = "Option::is_none")]
39        pub optional: Option<bool>,
40    }
41);
42
43generate_io_file_methods!(FileInput);
44
45impl FileInput {
46    pub fn from_uri(uri: Uri) -> Result<Self, ParseError> {
47        let mut input = Self {
48            file: FilePath::parse(&uri.path)?,
49            ..Default::default()
50        };
51
52        for (key, value) in uri.query {
53            match key.as_str() {
54                "content" | "match" | "matches" => {
55                    if !value.is_empty() {
56                        input.content = Some(RegexSetting::new(value).map_err(map_parse_error)?);
57                    }
58                }
59                "optional" => {
60                    input.optional = Some(parse_bool_field(&key, &value)?);
61                }
62                _ => {
63                    return Err(ParseError::new(format!("unknown file field `{key}`")));
64                }
65            };
66        }
67
68        Ok(input)
69    }
70}
71
72config_unit_enum!(
73    /// Available formats to resolve the file group into.
74    #[derive(ConfigEnum)]
75    pub enum FileGroupInputFormat {
76        /// Return the group as-is.
77        #[default]
78        Static,
79        /// Return only directories.
80        Dirs,
81        /// Return only environment variables.
82        Envs,
83        /// Return only files.
84        Files,
85        /// Return only globs.
86        Globs,
87        /// Return the lowest common root of all paths.
88        Root,
89    }
90);
91
92config_struct!(
93    /// A file group input.
94    #[derive(Config)]
95    pub struct FileGroupInput {
96        /// The file group identifier.
97        pub group: Id,
98
99        /// Format to resolve the file group into.
100        #[serde(default, alias = "as")]
101        pub format: FileGroupInputFormat,
102    }
103);
104
105impl FileGroupInput {
106    pub fn from_uri(uri: Uri) -> Result<Self, ParseError> {
107        let mut input = Self {
108            group: if uri.path.is_empty() {
109                return Err(ParseError::new("a file group identifier is required"));
110            } else {
111                Id::new(&uri.path).map_err(map_parse_error)?
112            },
113            ..Default::default()
114        };
115
116        for (key, value) in uri.query {
117            match key.as_str() {
118                "as" | "format" => {
119                    input.format =
120                        FileGroupInputFormat::from_str(&value).map_err(map_parse_error)?
121                }
122                _ => {
123                    return Err(ParseError::new(format!("unknown file group field `{key}`")));
124                }
125            };
126        }
127
128        Ok(input)
129    }
130}
131
132config_struct!(
133    /// A glob pattern input.
134    #[derive(Config)]
135    pub struct GlobInput {
136        /// The glob pattern.
137        pub glob: GlobPath,
138
139        /// Cache the glob walking result for increased performance.
140        #[serde(default = "default_true", skip_serializing_if = "is_false")]
141        #[setting(default = true)]
142        pub cache: bool,
143    }
144);
145
146generate_io_glob_methods!(GlobInput);
147
148impl GlobInput {
149    pub fn from_uri(uri: Uri) -> Result<Self, ParseError> {
150        let mut input = Self {
151            glob: GlobPath::parse(uri.path.replace("__QM__", "?"))?,
152            ..Default::default()
153        };
154
155        for (key, value) in uri.query {
156            match key.as_str() {
157                "cache" => {
158                    input.cache = parse_bool_field(&key, &value)?;
159                }
160                _ => {
161                    return Err(ParseError::new(format!("unknown glob field `{key}`")));
162                }
163            };
164        }
165
166        Ok(input)
167    }
168}
169
170config_struct!(
171    /// A manifest file's dependencies input.
172    #[derive(Config)]
173    pub struct ManifestDepsInput {
174        /// The toolchain identifier.
175        pub manifest: Id,
176
177        /// List of dependencies to compare against when
178        /// determining affected status.
179        #[serde(
180            default,
181            alias = "dep",
182            alias = "dependencies",
183            skip_serializing_if = "Vec::is_empty"
184        )]
185        pub deps: Vec<String>,
186    }
187);
188
189impl ManifestDepsInput {
190    pub fn from_uri(uri: Uri) -> Result<Self, ParseError> {
191        let mut input = Self {
192            manifest: if uri.path.is_empty() {
193                return Err(ParseError::new("a toolchain identifier is required"));
194            } else {
195                Id::new(&uri.path).map_err(map_parse_error)?
196            },
197            ..Default::default()
198        };
199
200        for (key, value) in uri.query {
201            match key.as_str() {
202                "dep" | "deps" | "dependencies" => {
203                    for val in value.split(',') {
204                        if !val.is_empty() {
205                            input.deps.push(val.trim().to_owned());
206                        }
207                    }
208                }
209                _ => {
210                    return Err(ParseError::new(format!("unknown manifest field `{key}`")));
211                }
212            };
213        }
214
215        Ok(input)
216    }
217}
218
219config_struct!(
220    /// An external project input.
221    #[derive(Config)]
222    pub struct ProjectInput {
223        // This is not an `Id` as we need to support `^` scopes!
224        /// The external project identifier.
225        pub project: String,
226
227        /// A list of globs, relative from the project's root,
228        /// in which to determine affected status.
229        #[serde(default, skip_serializing_if = "Vec::is_empty")]
230        pub filter: Vec<String>,
231
232        /// A file group identifier within the project in which
233        /// to determine affected status.
234        #[serde(default, alias = "fileGroup", skip_serializing_if = "Option::is_none")]
235        pub group: Option<Id>,
236    }
237);
238
239impl ProjectInput {
240    pub fn from_uri(uri: Uri) -> Result<Self, ParseError> {
241        let mut input = Self {
242            project: if uri.path.is_empty() {
243                return Err(ParseError::new("a project identifier is required"));
244            } else if uri.path.starts_with('^') {
245                uri.path
246            } else {
247                Id::new(&uri.path).map_err(map_parse_error)?.to_string()
248            },
249            ..Default::default()
250        };
251
252        for (key, value) in uri.query {
253            match key.as_str() {
254                "filter" => {
255                    if !value.is_empty() {
256                        input.filter.push(value);
257                    }
258                }
259                "fileGroup" | "filegroup" | "group" => {
260                    if !value.is_empty() {
261                        input.group = Some(Id::new(&value).map_err(map_parse_error)?);
262                    }
263                }
264                _ => {
265                    return Err(ParseError::new(format!("unknown project field `{key}`")));
266                }
267            };
268        }
269
270        Ok(input)
271    }
272
273    pub fn is_all_deps(&self) -> bool {
274        self.project == "^"
275    }
276
277    pub fn get_deps_scope(&self) -> Option<DependencyScope> {
278        match self.project.as_str() {
279            "^dev" | "^development" => Some(DependencyScope::Development),
280            "^prod" | "^production" => Some(DependencyScope::Production),
281            "^peer" => Some(DependencyScope::Peer),
282            "^build" => Some(DependencyScope::Build),
283            _ => None,
284        }
285    }
286}
287
288/// The different patterns a task input can be defined as.
289#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
290#[serde(try_from = "InputShape")]
291pub enum Input {
292    EnvVar(String),
293    EnvVarGlob(String),
294    File(FileInput),
295    FileGroup(FileGroupInput),
296    Glob(GlobInput),
297    Project(ProjectInput),
298    // Old
299    TokenFunc(String),
300    TokenVar(String),
301    // New
302    // ManifestDeps(ManifestDepsInput),
303}
304
305impl Input {
306    pub fn create_uri(value: &str) -> Result<Uri, ParseError> {
307        // Always use forward slashes
308        let mut value = standardize_separators(value);
309
310        // Convert literal paths to a URI
311        if !value.contains("://") {
312            if is_glob_like(&value) {
313                value = format!("glob://{}", value.replace("?", "__QM__"));
314            } else {
315                value = format!("file://{value}");
316            }
317        }
318
319        Uri::parse(&value)
320    }
321
322    pub fn parse(value: impl AsRef<str>) -> Result<Self, ParseError> {
323        Self::from_str(value.as_ref())
324    }
325
326    pub fn as_str(&self) -> &str {
327        match self {
328            Self::EnvVar(value)
329            | Self::EnvVarGlob(value)
330            | Self::TokenFunc(value)
331            | Self::TokenVar(value) => value,
332            Self::File(value) => value.file.as_str(),
333            Self::FileGroup(value) => value.group.as_str(),
334            Self::Glob(value) => value.glob.as_str(),
335            Self::Project(value) => value.project.as_str(),
336        }
337    }
338
339    pub fn is_glob(&self) -> bool {
340        matches!(self, Self::EnvVarGlob(_) | Self::Glob(_))
341    }
342}
343
344impl FromStr for Input {
345    type Err = ParseError;
346
347    fn from_str(value: &str) -> Result<Self, Self::Err> {
348        // Token function
349        if value.starts_with('@') && patterns::TOKEN_FUNC_DISTINCT.is_match(value) {
350            return Ok(Self::TokenFunc(value.to_owned()));
351        }
352
353        // Token/environment variable
354        if let Some(var) = value.strip_prefix('$') {
355            if patterns::ENV_VAR_DISTINCT.is_match(value) {
356                return Ok(Self::EnvVar(var.to_owned()));
357            } else if patterns::ENV_VAR_GLOB_DISTINCT.is_match(value) {
358                return Ok(Self::EnvVarGlob(var.to_owned()));
359            } else if patterns::TOKEN_VAR_DISTINCT.is_match(value) {
360                return Ok(Self::TokenVar(value.to_owned()));
361            }
362        }
363
364        // URI formats
365        let uri = Self::create_uri(value)?;
366
367        match uri.scheme.as_str() {
368            "file" => Ok(Self::File(FileInput::from_uri(uri)?)),
369            "glob" => Ok(Self::Glob(GlobInput::from_uri(uri)?)),
370            "group" | "filegroup" | "fileGroup" => {
371                Ok(Self::FileGroup(FileGroupInput::from_uri(uri)?))
372            }
373            "project" => Ok(Self::Project(ProjectInput::from_uri(uri)?)),
374            other => Err(ParseError::new(format!(
375                "input protocol `{other}://` is not supported"
376            ))),
377        }
378    }
379}
380
381impl Schematic for Input {
382    fn schema_name() -> Option<String> {
383        Some("Input".into())
384    }
385
386    fn build_schema(mut schema: SchemaBuilder) -> Schema {
387        schema.union(UnionType::new_any([
388            schema.infer::<String>(),
389            schema.infer::<FileInput>(),
390            schema.infer::<FileGroupInput>(),
391            schema.infer::<GlobInput>(),
392            schema.infer::<ProjectInput>(),
393        ]))
394    }
395}
396
397impl Serialize for Input {
398    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
399    where
400        S: Serializer,
401    {
402        match self {
403            Input::EnvVar(var) | Input::EnvVarGlob(var) => {
404                serializer.serialize_str(format!("${var}").as_str())
405            }
406            Input::TokenFunc(token) | Input::TokenVar(token) => serializer.serialize_str(token),
407            Input::File(input) => FileInput::serialize(input, serializer),
408            Input::FileGroup(input) => FileGroupInput::serialize(input, serializer),
409            // Input::ManifestDeps(input) => ManifestDepsInput::serialize(input, serializer),
410            Input::Glob(input) => GlobInput::serialize(input, serializer),
411            Input::Project(input) => ProjectInput::serialize(input, serializer),
412        }
413    }
414}
415
416#[derive(DeserializeUntaggedVerboseError)]
417enum InputShape {
418    String(String),
419    // From most complex to least
420    Project(ProjectInput),
421    FileGroup(FileGroupInput),
422    File(FileInput),
423    Glob(GlobInput),
424}
425
426impl TryFrom<InputShape> for Input {
427    type Error = ParseError;
428
429    fn try_from(base: InputShape) -> Result<Self, Self::Error> {
430        match base {
431            InputShape::String(input) => Self::parse(input),
432            InputShape::File(input) => Ok(Self::File(input)),
433            InputShape::FileGroup(input) => Ok(Self::FileGroup(input)),
434            InputShape::Glob(input) => Ok(Self::Glob(input)),
435            InputShape::Project(input) => Ok(Self::Project(input)),
436        }
437    }
438}