1use 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#[derive(Debug, Error)]
19pub enum DependencySourceError {
20 #[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 reason: &'static str,
34 },
35
36 #[error(transparent)]
38 VersionRequirement(#[from] VersionRequirementError),
39
40 #[error("Git dependency sub-path is invalid")]
42 GitSubpath(#[source] RelativePathError),
43
44 #[error(transparent)]
46 GitCommit(#[from] GitCommitError),
47
48 #[error("invalid Git URL: {0}")]
50 InvalidUrl(String),
51}
52
53#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(try_from = "DependencySourceFields", into = "DependencySourceFields")]
59pub enum DependencySource {
60 Git {
62 url: Url,
64 selector: GitSelector,
66 path: Option<RelativePath>,
68 extra: serde_json::Map<String, serde_json::Value>,
70 },
71 LocalPath {
73 path: PathBuf,
75 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 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#[derive(Clone, Debug, PartialEq, Eq)]
154pub enum GitSelector {
155 Version(VersionRequirement),
157 Tag(String),
159 Branch(String),
161 Commit(GitCommit),
163}
164
165#[derive(Debug, Default, Serialize, Deserialize)]
169struct DependencySourceFields {
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 git: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 path: Option<PathBuf>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 version: Option<String>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 tag: Option<String>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 branch: Option<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 commit: Option<String>,
188 #[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}