Skip to main content

wdl_modules/dependency/
source.rs

1//! Dependency-source parsing for `modules.json`.
2
3use std::path::PathBuf;
4
5use serde::Deserialize;
6use serde::Serialize;
7use thiserror::Error;
8use url::Url;
9
10use crate::GitCommit;
11use crate::GitCommitError;
12use crate::RelativePath;
13use crate::RelativePathError;
14use crate::VersionRequirement;
15use crate::VersionRequirementError;
16
17/// An error parsing a [`DependencySource`].
18#[derive(Debug, Error)]
19pub enum DependencySourceError {
20    /// The dependency does not name a valid source.
21    ///
22    /// The dependency must specify exactly one of `path` for a local-path
23    /// source, or `git` with exactly one of `version`, `tag`, `branch`, or
24    /// `commit` for a Git source. The `reason` describes which rule was
25    /// violated by the input.
26    #[error(
27        "dependency source is invalid: {reason}; must specify either `path` for a local-path \
28         source, or `git` with exactly one of `version`, `tag`, `branch`, or `commit` for a Git \
29         source"
30    )]
31    InvalidSource {
32        /// A short description of which validation rule was violated.
33        reason: &'static str,
34    },
35
36    /// A version requirement on a Git dependency was invalid.
37    #[error(transparent)]
38    VersionRequirement(#[from] VersionRequirementError),
39
40    /// A Git dependency sub-path was invalid.
41    #[error("Git dependency sub-path is invalid")]
42    GitSubpath(#[source] RelativePathError),
43
44    /// A Git commit selector was invalid.
45    #[error(transparent)]
46    GitCommit(#[from] GitCommitError),
47
48    /// The Git URL did not parse.
49    #[error("invalid Git URL: {0}")]
50    InvalidUrl(String),
51}
52
53/// A dependency source.
54///
55/// The two possible sources are a Git repository (with one of four
56/// selectors plus an optional sub-path) or a local filesystem path.
57#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(try_from = "DependencySourceFields", into = "DependencySourceFields")]
59pub enum DependencySource {
60    /// A Git-backed dependency.
61    Git {
62        /// The Git repository URL.
63        url: Url,
64        /// The selector controlling which revision to resolve to.
65        selector: GitSelector,
66        /// Optional sub-path within the repository where the module lives.
67        path: Option<RelativePath>,
68        /// Unknown fields, preserved for round-trip and inspection.
69        extra: serde_json::Map<String, serde_json::Value>,
70    },
71    /// A local filesystem dependency.
72    LocalPath {
73        /// The path to the local module directory.
74        path: PathBuf,
75        /// Unknown fields, preserved for round-trip and inspection.
76        extra: serde_json::Map<String, serde_json::Value>,
77    },
78}
79
80impl TryFrom<DependencySourceFields> for DependencySource {
81    type Error = DependencySourceError;
82
83    fn try_from(fields: DependencySourceFields) -> Result<Self, Self::Error> {
84        let DependencySourceFields {
85            git,
86            path,
87            version,
88            tag,
89            branch,
90            commit,
91            extra,
92        } = fields;
93
94        let selector_count = [&version, &tag, &branch, &commit]
95            .iter()
96            .filter(|s| s.is_some())
97            .count();
98
99        match (git, path) {
100            (Some(g), git_subpath) => {
101                if selector_count == 0 {
102                    return Err(DependencySourceError::InvalidSource {
103                        reason: "Git dependency is missing a selector",
104                    });
105                }
106                if selector_count > 1 {
107                    return Err(DependencySourceError::InvalidSource {
108                        reason: "Git dependency specifies more than one selector",
109                    });
110                }
111                let url =
112                    Url::parse(&g).map_err(|e| DependencySourceError::InvalidUrl(e.to_string()))?;
113                let selector = if let Some(v) = version {
114                    GitSelector::Version(VersionRequirement::try_from(v)?)
115                } else if let Some(t) = tag {
116                    GitSelector::Tag(t)
117                } else if let Some(b) = branch {
118                    GitSelector::Branch(b)
119                } else if let Some(c) = commit {
120                    GitSelector::Commit(GitCommit::try_from(c)?)
121                } else {
122                    // SAFETY: `selector_count` is 1 in this branch, and the
123                    // four `if let Some(...)` arms above cover every selector
124                    // field, so one of them must match.
125                    unreachable!()
126                };
127                Ok(Self::Git {
128                    url,
129                    selector,
130                    path: git_subpath
131                        .map(RelativePath::try_from)
132                        .transpose()
133                        .map_err(DependencySourceError::GitSubpath)?,
134                    extra,
135                })
136            }
137            (None, Some(p)) => {
138                if selector_count > 0 {
139                    return Err(DependencySourceError::InvalidSource {
140                        reason: "local-path dependency cannot specify a selector",
141                    });
142                }
143                Ok(Self::LocalPath { path: p, extra })
144            }
145            (None, None) => Err(DependencySourceError::InvalidSource {
146                reason: "neither `git` nor `path` was specified",
147            }),
148        }
149    }
150}
151
152/// A Git revision selector.
153#[derive(Clone, Debug, PartialEq, Eq)]
154pub enum GitSelector {
155    /// A semver requirement matched against the repository's tags.
156    Version(VersionRequirement),
157    /// An exact Git tag name.
158    Tag(String),
159    /// A Git branch name.
160    Branch(String),
161    /// A full Git commit SHA.
162    Commit(GitCommit),
163}
164
165/// Flat field set of a dependency declaration as it appears in
166/// `module.json`, before mutual-exclusion validation projects it onto
167/// [`DependencySource`].
168#[derive(Debug, Default, Serialize, Deserialize)]
169struct DependencySourceFields {
170    /// The Git URL, if the dependency is Git-backed.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    git: Option<String>,
173    /// Either the local-path source, or a sub-path within a Git source.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    path: Option<PathBuf>,
176    /// The semver requirement on a Git source.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    version: Option<String>,
179    /// The Git tag selector.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    tag: Option<String>,
182    /// The Git branch selector.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    branch: Option<String>,
185    /// The Git commit selector.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    commit: Option<String>,
188    /// Unknown fields, preserved for round-trip and inspection.
189    #[serde(flatten)]
190    extra: serde_json::Map<String, serde_json::Value>,
191}
192
193impl From<DependencySource> for DependencySourceFields {
194    fn from(source: DependencySource) -> Self {
195        match source {
196            DependencySource::Git {
197                url,
198                selector,
199                path,
200                extra,
201            } => {
202                let mut fields = DependencySourceFields {
203                    git: Some(url.to_string()),
204                    path: path.map(PathBuf::from),
205                    extra,
206                    ..Default::default()
207                };
208                match selector {
209                    GitSelector::Version(v) => fields.version = Some(v.to_string()),
210                    GitSelector::Tag(t) => fields.tag = Some(t),
211                    GitSelector::Branch(b) => fields.branch = Some(b),
212                    GitSelector::Commit(c) => fields.commit = Some(c.to_string()),
213                }
214                fields
215            }
216            DependencySource::LocalPath { path, extra } => DependencySourceFields {
217                path: Some(path),
218                extra,
219                ..Default::default()
220            },
221        }
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn parse(s: &str) -> Result<DependencySource, serde_json::Error> {
230        serde_json::from_str(s)
231    }
232
233    #[test]
234    fn parses_git_with_version() {
235        let dep = parse(r#"{"git": "https://github.com/x/y", "version": "^1.0.0"}"#).unwrap();
236        match dep {
237            DependencySource::Git {
238                selector: GitSelector::Version(_),
239                ..
240            } => {}
241            _ => panic!("expected `Version` selector"),
242        }
243    }
244
245    #[test]
246    fn parses_git_with_tag() {
247        let dep = parse(r#"{"git": "https://github.com/x/y", "tag": "v1.2.3"}"#).unwrap();
248        assert!(matches!(
249            dep,
250            DependencySource::Git {
251                selector: GitSelector::Tag(_),
252                ..
253            }
254        ));
255    }
256
257    #[test]
258    fn parses_git_with_branch() {
259        let dep = parse(r#"{"git": "https://github.com/x/y", "branch": "main"}"#).unwrap();
260        assert!(matches!(
261            dep,
262            DependencySource::Git {
263                selector: GitSelector::Branch(_),
264                ..
265            }
266        ));
267    }
268
269    #[test]
270    fn parses_git_with_commit() {
271        let dep = parse(
272            r#"{
273                "git": "https://github.com/x/y",
274                "commit": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
275            }"#,
276        )
277        .unwrap();
278        match dep {
279            DependencySource::Git {
280                selector: GitSelector::Commit(commit),
281                ..
282            } => assert_eq!(commit.as_str(), "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"),
283            _ => panic!("expected `Commit` selector"),
284        }
285    }
286
287    #[test]
288    fn parses_local_path() {
289        let dep = parse(r#"{"path": "../local"}"#).unwrap();
290        assert!(matches!(dep, DependencySource::LocalPath { .. }));
291    }
292
293    #[test]
294    fn parses_git_with_subpath() {
295        let dep = parse(r#"{"git": "https://github.com/x/y", "version": "^1.0.0", "path": "wdl"}"#)
296            .unwrap();
297        match dep {
298            DependencySource::Git {
299                selector: GitSelector::Version(_),
300                path: Some(p),
301                ..
302            } => assert_eq!(p.as_path(), std::path::Path::new("wdl")),
303            _ => panic!("expected Git source with sub-path"),
304        }
305    }
306
307    #[test]
308    fn rejects_invalid_git_subpaths() {
309        for bad in [
310            r#"{"git": "https://x/y", "version": "^1", "path": "/abs"}"#,
311            r#"{"git": "https://x/y", "version": "^1", "path": "../escape"}"#,
312        ] {
313            assert!(parse(bad).is_err(), "accepted `{bad}`");
314        }
315    }
316
317    #[test]
318    fn rejects_short_commit_selector() {
319        let err = parse(r#"{"git": "https://x/y", "commit": "abc123"}"#).unwrap_err();
320        assert!(
321            err.to_string()
322                .contains("must be exactly 40 lowercase hex characters"),
323            "wrong error: {err}"
324        );
325    }
326
327    #[test]
328    fn captures_unknown_fields() {
329        let dep =
330            parse(r#"{"git": "https://github.com/x/y", "version": "^1.0.0", "deprecated": true}"#)
331                .unwrap();
332        match dep {
333            DependencySource::Git { extra, .. } => {
334                assert_eq!(
335                    extra.get("deprecated"),
336                    Some(&serde_json::Value::Bool(true))
337                );
338            }
339            _ => panic!("expected Git source"),
340        }
341    }
342
343    #[test]
344    fn rejects_invalid_structures() {
345        for bad in [
346            r#"{"git": "https://x/y", "version": "^1", "tag": "v1"}"#,
347            r#"{"git": "https://x/y"}"#,
348            r#"{"path": "p", "version": "^1"}"#,
349            r#"{}"#,
350        ] {
351            let err = parse(bad).unwrap_err();
352            assert!(
353                err.to_string().contains("dependency source is invalid"),
354                "wrong message for `{bad}`: {err}"
355            );
356        }
357    }
358}