moon_config/shapes/
output_path.rs

1#![allow(clippy::from_over_into)]
2
3use crate::patterns;
4use crate::portable_path::is_glob_like;
5use crate::validate::validate_child_relative_path;
6use moon_common::path::{
7    RelativeFrom, WorkspaceRelativePathBuf, expand_to_workspace_relative, standardize_separators,
8};
9use schematic::{ParseError, Schema, SchemaBuilder, Schematic, derive_enum};
10use std::cmp::Ordering;
11use std::str::FromStr;
12
13derive_enum!(
14    /// The different patterns a task output can be defined.
15    #[serde(untagged, into = "String", try_from = "String")]
16    pub enum OutputPath {
17        ProjectFile(String),
18        ProjectGlob(String),
19        TokenFunc(String),
20        TokenVar(String),
21        WorkspaceFile(String),
22        WorkspaceGlob(String),
23    }
24);
25
26impl OutputPath {
27    pub fn as_str(&self) -> &str {
28        match self {
29            Self::ProjectFile(value)
30            | Self::ProjectGlob(value)
31            | Self::TokenFunc(value)
32            | Self::TokenVar(value)
33            | Self::WorkspaceFile(value)
34            | Self::WorkspaceGlob(value) => value,
35        }
36    }
37
38    pub fn is_glob(&self) -> bool {
39        matches!(self, Self::ProjectGlob(_) | Self::WorkspaceGlob(_))
40    }
41
42    pub fn to_workspace_relative(
43        &self,
44        project_source: impl AsRef<str>,
45    ) -> Option<WorkspaceRelativePathBuf> {
46        match self {
47            Self::ProjectFile(path) | Self::ProjectGlob(path) => Some(
48                expand_to_workspace_relative(RelativeFrom::Project(project_source.as_ref()), path),
49            ),
50            Self::WorkspaceFile(path) | Self::WorkspaceGlob(path) => {
51                Some(expand_to_workspace_relative(RelativeFrom::Workspace, path))
52            }
53            _ => None,
54        }
55    }
56}
57
58impl AsRef<str> for OutputPath {
59    fn as_ref(&self) -> &str {
60        self.as_str()
61    }
62}
63
64impl AsRef<OutputPath> for OutputPath {
65    fn as_ref(&self) -> &OutputPath {
66        self
67    }
68}
69
70impl PartialOrd<OutputPath> for OutputPath {
71    fn partial_cmp(&self, other: &OutputPath) -> Option<Ordering> {
72        Some(self.cmp(other))
73    }
74}
75
76impl Ord for OutputPath {
77    fn cmp(&self, other: &Self) -> Ordering {
78        self.as_str().cmp(other.as_str())
79    }
80}
81
82impl FromStr for OutputPath {
83    type Err = ParseError;
84
85    fn from_str(value: &str) -> Result<Self, Self::Err> {
86        // Token function
87        if value.starts_with('@') && patterns::TOKEN_FUNC_DISTINCT.is_match(value) {
88            return Ok(Self::TokenFunc(value.to_owned()));
89        }
90
91        // Token/env var
92        if value.starts_with('$') {
93            if patterns::ENV_VAR_DISTINCT.is_match(value) {
94                return Err(ParseError::new(
95                    "environment variable is not supported by itself",
96                ));
97            } else if patterns::ENV_VAR_GLOB_DISTINCT.is_match(value) {
98                return Err(ParseError::new(
99                    "environment variable globs are not supported",
100                ));
101            } else if patterns::TOKEN_VAR_DISTINCT.is_match(value) {
102                return Ok(Self::TokenVar(value.to_owned()));
103            }
104        }
105
106        let value = standardize_separators(value);
107
108        // Workspace negated glob
109        if value.starts_with("/!") || value.starts_with("!/") {
110            return Ok(Self::WorkspaceGlob(format!("!{}", &value[2..])));
111        }
112
113        // Workspace-relative
114        if let Some(workspace_path) = value.strip_prefix('/') {
115            validate_child_relative_path(workspace_path)
116                .map_err(|error| ParseError::new(error.to_string()))?;
117
118            return Ok(if is_glob_like(workspace_path) {
119                Self::WorkspaceGlob(workspace_path.to_owned())
120            } else {
121                Self::WorkspaceFile(workspace_path.to_owned())
122            });
123        }
124
125        // Project-relative
126        validate_child_relative_path(&value).map_err(|error| ParseError::new(error.to_string()))?;
127
128        let project_path = value.trim_start_matches("./");
129
130        Ok(if is_glob_like(project_path) {
131            Self::ProjectGlob(project_path.to_owned())
132        } else {
133            Self::ProjectFile(project_path.to_owned())
134        })
135    }
136}
137
138impl TryFrom<String> for OutputPath {
139    type Error = ParseError;
140
141    fn try_from(value: String) -> Result<Self, Self::Error> {
142        Self::from_str(&value)
143    }
144}
145
146impl Into<String> for OutputPath {
147    fn into(self) -> String {
148        match self {
149            Self::ProjectFile(value)
150            | Self::ProjectGlob(value)
151            | Self::TokenFunc(value)
152            | Self::TokenVar(value) => value,
153            Self::WorkspaceFile(path) | Self::WorkspaceGlob(path) => format!("/{path}"),
154        }
155    }
156}
157
158impl Schematic for OutputPath {
159    fn build_schema(mut schema: SchemaBuilder) -> Schema {
160        schema.string_default()
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn parses_correctly() {
170        // Project relative
171        assert_eq!(
172            OutputPath::from_str("file.rs").unwrap(),
173            OutputPath::ProjectFile("file.rs".into())
174        );
175        assert_eq!(
176            OutputPath::from_str("dir/file.rs").unwrap(),
177            OutputPath::ProjectFile("dir/file.rs".into())
178        );
179        assert_eq!(
180            OutputPath::from_str("dir/**/*").unwrap(),
181            OutputPath::ProjectGlob("dir/**/*".into())
182        );
183        assert_eq!(
184            OutputPath::from_str("!dir/**/*").unwrap(),
185            OutputPath::ProjectGlob("!dir/**/*".into())
186        );
187        assert_eq!(
188            OutputPath::from_str("./file.rs").unwrap(),
189            OutputPath::ProjectFile("file.rs".into())
190        );
191        assert_eq!(
192            OutputPath::from_str("./dir/file.rs").unwrap(),
193            OutputPath::ProjectFile("dir/file.rs".into())
194        );
195        assert_eq!(
196            OutputPath::from_str("././dir/**/*").unwrap(),
197            OutputPath::ProjectGlob("dir/**/*".into())
198        );
199
200        // Workspace relative
201        assert_eq!(
202            OutputPath::from_str("/file.rs").unwrap(),
203            OutputPath::WorkspaceFile("file.rs".into())
204        );
205        assert_eq!(
206            OutputPath::from_str("/dir/file.rs").unwrap(),
207            OutputPath::WorkspaceFile("dir/file.rs".into())
208        );
209        assert_eq!(
210            OutputPath::from_str("/dir/**/*").unwrap(),
211            OutputPath::WorkspaceGlob("dir/**/*".into())
212        );
213        assert_eq!(
214            OutputPath::from_str("!/dir/**/*").unwrap(),
215            OutputPath::WorkspaceGlob("!dir/**/*".into())
216        );
217        assert_eq!(
218            OutputPath::from_str("/!dir/**/*").unwrap(),
219            OutputPath::WorkspaceGlob("!dir/**/*".into())
220        );
221    }
222
223    #[test]
224    fn parses_tokens() {
225        // Functions
226        assert_eq!(
227            OutputPath::from_str("@group(name)").unwrap(),
228            OutputPath::TokenFunc("@group(name)".into())
229        );
230        assert_eq!(
231            OutputPath::from_str("@dirs(name)").unwrap(),
232            OutputPath::TokenFunc("@dirs(name)".into())
233        );
234        assert_eq!(
235            OutputPath::from_str("@files(name)").unwrap(),
236            OutputPath::TokenFunc("@files(name)".into())
237        );
238        assert_eq!(
239            OutputPath::from_str("@globs(name)").unwrap(),
240            OutputPath::TokenFunc("@globs(name)".into())
241        );
242        assert_eq!(
243            OutputPath::from_str("@root(name)").unwrap(),
244            OutputPath::TokenFunc("@root(name)".into())
245        );
246
247        // Vars
248        assert_eq!(
249            OutputPath::from_str("$workspaceRoot").unwrap(),
250            OutputPath::TokenVar("$workspaceRoot".into())
251        );
252        assert_eq!(
253            OutputPath::from_str("$projectType").unwrap(),
254            OutputPath::TokenVar("$projectType".into())
255        );
256    }
257
258    #[test]
259    #[should_panic(expected = "environment variable globs are not supported")]
260    fn errors_for_env_globs() {
261        OutputPath::from_str("$VAR_*").unwrap();
262    }
263
264    #[test]
265    #[should_panic(expected = "parent relative paths are not supported")]
266    fn errors_for_parent_relative_from_project() {
267        OutputPath::from_str("../test").unwrap();
268    }
269
270    #[test]
271    #[should_panic(expected = "parent relative paths are not supported")]
272    fn errors_for_parent_relative_from_workspace() {
273        OutputPath::from_str("/../test").unwrap();
274    }
275}