Skip to main content

soldeer_core/
registry.rs

1//! Soldeer registry client.
2//!
3//! The registry client is responsible for fetching information about packages from the Soldeer
4//! registry at <https://soldeer.xyz>.
5use crate::{
6    auth::get_auth_headers,
7    config::{Dependency, HttpDependency},
8    errors::RegistryError,
9};
10use chrono::{DateTime, Utc};
11use log::{debug, warn};
12use reqwest::{Client, Url};
13use semver::{Version, VersionReq};
14use serde::Deserialize;
15use std::env;
16
17pub type Result<T> = std::result::Result<T, RegistryError>;
18
19/// A revision (version) for a project (package).
20#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize))]
22pub struct Revision {
23    /// The unique ID for the revision.
24    pub id: uuid::Uuid,
25
26    /// The version of the revision.
27    pub version: String,
28
29    /// The internal name (path of zip file) for the revision.
30    pub internal_name: String,
31
32    /// The zip file download URL.
33    pub url: String,
34
35    /// The project unique ID.
36    pub project_id: uuid::Uuid,
37
38    /// Whether this revision has been deleted.
39    pub deleted: bool,
40
41    /// Creation date for the revision.
42    pub created_at: Option<DateTime<Utc>>,
43
44    /// Whether the revision is private.
45    pub private: Option<bool>,
46}
47
48/// A project (package) in the registry.
49#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
50#[cfg_attr(feature = "serde", derive(serde::Serialize))]
51pub struct Project {
52    /// The unique ID for the project.
53    pub id: uuid::Uuid,
54
55    /// The name of the project.
56    pub name: String,
57
58    /// The description of the project.
59    pub description: String,
60
61    /// The URL of the repository on GitHub.
62    pub github_url: String,
63
64    /// The unique ID for the owner of the project.
65    pub created_by: uuid::Uuid,
66
67    /// Whether this project has been deleted.
68    pub deleted: Option<bool>,
69
70    /// Whether the project is private.
71    pub private: Option<bool>,
72
73    /// Other metadata below
74    pub downloads: Option<i64>,
75    pub image: Option<String>,
76    pub long_description: Option<String>,
77    pub created_at: Option<DateTime<Utc>>,
78    pub updated_at: Option<DateTime<Utc>>,
79    pub organization_id: Option<uuid::Uuid>,
80    pub latest_version: Option<String>,
81    pub deprecated: Option<bool>,
82    pub organization_name: Option<String>,
83    pub organization_verified: Option<bool>,
84}
85
86/// The response from the revision endpoint.
87#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
88#[cfg_attr(feature = "serde", derive(serde::Serialize))]
89pub struct RevisionResponse {
90    /// The revisions.
91    data: Vec<Revision>,
92
93    /// The status of the response.
94    status: String,
95}
96
97/// The response from the project endpoint.
98#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
99#[cfg_attr(feature = "serde", derive(serde::Serialize))]
100pub struct ProjectResponse {
101    /// The projects.
102    data: Vec<Project>,
103
104    /// The status of the response.
105    status: String,
106}
107
108/// A download URL for a revision.
109#[derive(Debug, Clone, PartialEq, Eq, Hash)]
110#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
111pub struct DownloadUrl {
112    /// The download URL.
113    pub url: String,
114
115    /// Whether this revision is private.
116    pub private: bool,
117}
118
119/// Construct a URL for the Soldeer API.
120///
121/// The URL is constructed from the `SOLDEER_API_URL` environment variable, or defaults to
122/// <https://api.soldeer.xyz>. The API version prefix and path are appended to the base URL,
123/// and any query parameters are URL-encoded and appended to the URL.
124///
125/// # Examples
126///
127/// ```
128/// # use soldeer_core::registry::api_url;
129/// let url = api_url(
130///     "v1",
131///     "revision",
132///     &[("project_name", "forge-std"), ("offset", "0"), ("limit", "1")],
133/// );
134/// assert_eq!(
135///     url.as_str(),
136///     "https://api.soldeer.xyz/api/v1/revision?project_name=forge-std&offset=0&limit=1"
137/// );
138/// ```
139pub fn api_url(version: &str, path: &str, params: &[(&str, &str)]) -> Url {
140    let url = env::var("SOLDEER_API_URL").unwrap_or("https://api.soldeer.xyz".to_string());
141    let mut url = Url::parse(&url).expect("SOLDEER_API_URL is invalid");
142    url.set_path(&format!("api/{version}/{path}"));
143    if params.is_empty() {
144        return url;
145    }
146    url.query_pairs_mut().extend_pairs(params.iter());
147    url
148}
149
150/// Get the download URL for a dependency at a specific version.
151pub async fn get_dependency_url_remote(
152    dependency: &Dependency,
153    version: &str,
154) -> Result<DownloadUrl> {
155    debug!(dep:% = dependency; "retrieving URL for dependency");
156    let url = api_url(
157        "v1",
158        "revision-cli",
159        &[("project_name", dependency.name()), ("revision", version)],
160    );
161
162    let res = Client::new().get(url).headers(get_auth_headers()?).send().await?;
163    let res = res.error_for_status()?;
164    let revision: RevisionResponse = res.json().await?;
165    let Some(r) = revision.data.first() else {
166        return Err(RegistryError::URLNotFound(dependency.to_string()));
167    };
168    debug!(dep:% = dependency, url = r.url; "URL for dependency was found");
169    Ok(DownloadUrl { url: r.url.clone(), private: r.private.unwrap_or_default() })
170}
171
172/// Get the unique ID for a project by name.
173pub async fn get_project_id(dependency_name: &str) -> Result<String> {
174    debug!(name = dependency_name; "retrieving project ID");
175    let url = api_url("v2", "project", &[("project_name", dependency_name)]);
176    let res = Client::new().get(url).headers(get_auth_headers()?).send().await?;
177    let res = res.error_for_status()?;
178    let project: ProjectResponse = res.json().await?;
179    let Some(p) = project.data.first() else {
180        return Err(RegistryError::ProjectNotFound(dependency_name.to_string()));
181    };
182    debug!(name = dependency_name, id:% = p.id; "project ID was found");
183    Ok(p.id.to_string())
184}
185
186/// Get the latest version of a dependency.
187pub async fn get_latest_version(dependency_name: &str) -> Result<Dependency> {
188    debug!(dep = dependency_name; "retrieving latest version for dependency");
189    let url = api_url(
190        "v1",
191        "revision",
192        &[("project_name", dependency_name), ("offset", "0"), ("limit", "1")],
193    );
194    let res = Client::new().get(url).headers(get_auth_headers()?).send().await?;
195    let res = res.error_for_status()?;
196    let revision: RevisionResponse = res.json().await?;
197    let Some(data) = revision.data.first() else {
198        return Err(RegistryError::URLNotFound(dependency_name.to_string()));
199    };
200    debug!(dep = dependency_name, version = data.version; "latest version found");
201    Ok(HttpDependency {
202        name: dependency_name.to_string(),
203        version_req: data.clone().version,
204        url: None,
205        project_root: None,
206    }
207    .into())
208}
209
210/// The versions of a dependency.
211///
212/// If all versions can be parsed as semver, then the versions are sorted in descending order
213/// according to semver. If not all versions can be parsed as semver, then the versions are returned
214/// in the order they were received from the API (descending creation date).
215#[derive(Debug, Clone, PartialEq, Eq, Hash)]
216pub enum Versions {
217    /// All versions are semver compliant.
218    Semver(Vec<Version>),
219
220    /// Not all versions are semver compliant.
221    NonSemver(Vec<String>),
222}
223
224/// Get all versions of a dependency sorted in descending order
225///
226/// If all versions can be parsed as semver, then the versions are sorted in descending order
227/// according to semver. If not all versions can be parsed as semver, then the versions are returned
228/// in the order they were received from the API (descending creation date).
229pub async fn get_all_versions_descending(dependency_name: &str) -> Result<Versions> {
230    // TODO: provide a more efficient endpoint which already sorts by descending semver if possible
231    // and only returns the version strings
232    debug!(dep = dependency_name; "retrieving all dependency versions");
233    let url = api_url(
234        "v1",
235        "revision",
236        &[("project_name", dependency_name), ("offset", "0"), ("limit", "10000")],
237    );
238    let res = Client::new().get(url).headers(get_auth_headers()?).send().await?;
239    let res = res.error_for_status()?;
240    let revision: RevisionResponse = res.json().await?;
241    if revision.data.is_empty() {
242        return Err(RegistryError::NoVersion(dependency_name.to_string()));
243    }
244
245    match revision
246        .data
247        .iter()
248        .map(|r| Version::parse(&r.version))
249        .collect::<std::result::Result<Vec<Version>, _>>()
250    {
251        Ok(mut versions) => {
252            debug!(dep = dependency_name; "all versions are semver compliant, sorting by descending version");
253            versions.sort_unstable_by(|a, b| b.cmp(a)); // sort in descending order
254            Ok(Versions::Semver(versions))
255        }
256        Err(_) => {
257            debug!(dep = dependency_name; "not all versions are semver compliant, using API ordering");
258            Ok(Versions::NonSemver(revision.data.iter().map(|r| r.version.to_string()).collect()))
259        }
260    }
261}
262
263/// Get the latest version of a dependency that satisfies the version requirement.
264///
265/// If the API response contains non-semver-compliant versions, then we attempt to find an exact
266/// match for the requirement, or error out.
267pub async fn get_latest_supported_version(dependency: &Dependency) -> Result<String> {
268    debug!(dep:% = dependency, version_req = dependency.version_req(); "retrieving latest version according to version requirement");
269    match get_all_versions_descending(dependency.name()).await? {
270        Versions::Semver(all_versions) => {
271            match parse_version_req(dependency.version_req()) {
272                Some(req) => {
273                    let new_version = all_versions
274                        .iter()
275                        .find(|version| req.matches(version))
276                        .ok_or(RegistryError::NoMatchingVersion {
277                            dependency: dependency.name().to_string(),
278                            version_req: dependency.version_req().to_string(),
279                        })?;
280                    debug!(dep:% = dependency, version:% = new_version; "acceptable version found");
281                    Ok(new_version.to_string())
282                }
283                None => {
284                    warn!(dep:% = dependency, version_req = dependency.version_req(); "could not parse version req according to semver, using latest version");
285                    // we can't check which version is newer, so we just take the latest one
286                    Ok(all_versions
287                        .into_iter()
288                        .next()
289                        .map(|v| v.to_string())
290                        .expect("there should be at least 1 version"))
291                }
292            }
293        }
294        Versions::NonSemver(all_versions) => {
295            // try to find the exact version specifier in the list of all versions, otherwise error
296            // out
297            debug!(dep:% = dependency; "versions are not all semver compliant, trying to find exact match");
298            all_versions.into_iter().find(|v| v == dependency.version_req()).ok_or_else(|| {
299                RegistryError::NoMatchingVersion {
300                    dependency: dependency.name().to_string(),
301                    version_req: dependency.version_req().to_string(),
302                }
303            })
304        }
305    }
306}
307
308/// Parse a version requirement string into a `VersionReq`.
309///
310/// Adds the "equal" operator to the req if it doesn't have an operator.
311/// This is necessary because the [`semver`] crate considers no operator to be equivalent to the
312/// "compatible" operator, but we want to treat it as the "equal" operator.
313pub fn parse_version_req(version_req: &str) -> Option<VersionReq> {
314    let Ok(mut req) = version_req.parse::<VersionReq>() else {
315        debug!(version_req; "version requirement cannot be parsed by semver");
316        return None;
317    };
318    if req.comparators.is_empty() {
319        debug!(version_req; "comparators list is empty (wildcard req), no further action needed");
320        return Some(req); // wildcard/any version
321    }
322    let orig_items: Vec<_> = version_req.split(',').collect();
323    // we only perform the operator conversion if we can reference the original string, i.e. if the
324    // parsed result has the same number of comparators as the original string
325
326    if orig_items.len() == req.comparators.len() {
327        for (comparator, orig) in req.comparators.iter_mut().zip(orig_items) {
328            if comparator.op == semver::Op::Caret && !orig.trim_start_matches(' ').starts_with('^')
329            {
330                debug!(comparator:% = comparator; "adding exact operator for comparator");
331                comparator.op = semver::Op::Exact;
332            }
333        }
334    }
335    Some(req)
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use mockito::{Matcher, Server};
342    use temp_env::async_with_vars;
343
344    #[tokio::test]
345    async fn test_get_dependency_url() {
346        let mut server = Server::new_async().await;
347        let data = r#"{"data":[{"created_at":"2024-08-06T17:31:25.751079Z","deleted":false,"downloads":3391,"id":"660132e6-4902-4804-8c4b-7cae0a648054","internal_name":"forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","version":"1.9.2"}],"status":"success"}"#;
348        server
349            .mock("GET", "/api/v1/revision-cli")
350            .match_query(Matcher::Any)
351            .with_header("content-type", "application/json")
352            .with_body(data)
353            .create_async()
354            .await;
355
356        let dependency =
357            HttpDependency::builder().name("forge-std").version_req("^1.9.0").build().into();
358        let res = async_with_vars(
359            [("SOLDEER_API_URL", Some(server.url()))],
360            get_dependency_url_remote(&dependency, "1.9.2"),
361        )
362        .await;
363        assert!(res.is_ok(), "{res:?}");
364        assert_eq!(
365            res.unwrap().url,
366            "https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip"
367        );
368    }
369
370    #[tokio::test]
371    async fn test_get_dependency_url_nomatch() {
372        let mut server = Server::new_async().await;
373        let data = r#"{"data":[],"status":"success"}"#;
374        server
375            .mock("GET", "/api/v1/revision-cli")
376            .match_query(Matcher::Any)
377            .with_header("content-type", "application/json")
378            .with_body(data)
379            .create_async()
380            .await;
381
382        let dependency =
383            HttpDependency::builder().name("forge-std").version_req("^1.9.0").build().into();
384        let res = async_with_vars(
385            [("SOLDEER_API_URL", Some(server.url()))],
386            get_dependency_url_remote(&dependency, "1.9.2"),
387        )
388        .await;
389        assert!(matches!(res, Err(RegistryError::URLNotFound(_))));
390    }
391
392    #[tokio::test]
393    async fn test_get_project_id() {
394        let mut server = Server::new_async().await;
395        let data = r#"{"data":[{"created_at":"2024-02-27T19:19:23.938837Z","created_by":"96228bb5-f777-4c19-ba72-363d14b8beed","deleted":false,"deprecated":false,"description":"Forge Standard Library is a collection of helpful contracts and libraries for use with Forge and Foundry.","downloads":648041,"github_url":"https://github.com/foundry-rs/forge-std","id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","image":"https://soldeer-resources.s3.amazonaws.com/default_icon.png","latest_version":"1.10.0","long_description":"Description","name":"forge-std","organization_id":"ff9c0d8e-9275-4f6f-a1b7-2e822450a7ba","organization_name":"Soldeer","organization_verified":true,"updated_at":"2024-02-27T19:19:23.938837Z"}],"status":"success"}"#;
396        server
397            .mock("GET", "/api/v2/project")
398            .match_query(Matcher::Any)
399            .with_header("content-type", "application/json")
400            .with_body(data)
401            .create_async()
402            .await;
403        let res =
404            async_with_vars([("SOLDEER_API_URL", Some(server.url()))], get_project_id("forge-std"))
405                .await;
406        assert!(res.is_ok(), "{res:?}");
407        assert_eq!(res.unwrap(), "37adefe5-9bc6-4777-aaf2-e56277d1f30b");
408    }
409
410    #[tokio::test]
411    async fn test_get_project_id_nomatch() {
412        let mut server = Server::new_async().await;
413        let data = r#"{"data":[],"status":"success"}"#;
414        server
415            .mock("GET", "/api/v2/project")
416            .match_query(Matcher::Any)
417            .with_header("content-type", "application/json")
418            .with_body(data)
419            .create_async()
420            .await;
421
422        let res =
423            async_with_vars([("SOLDEER_API_URL", Some(server.url()))], get_project_id("forge-std"))
424                .await;
425        assert!(matches!(res, Err(RegistryError::ProjectNotFound(_))));
426    }
427
428    #[tokio::test]
429    async fn test_get_latest_forge_std() {
430        let mut server = Server::new_async().await;
431        let data = r#"{"data":[{"created_at":"2024-08-06T17:31:25.751079Z","deleted":false,"downloads":3391,"id":"660132e6-4902-4804-8c4b-7cae0a648054","internal_name":"forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","version":"1.9.2"}],"status":"success"}"#;
432        server
433            .mock("GET", "/api/v1/revision")
434            .match_query(Matcher::Any)
435            .with_header("content-type", "application/json")
436            .with_body(data)
437            .create_async()
438            .await;
439
440        let dependency =
441            HttpDependency::builder().name("forge-std").version_req("1.9.2").build().into();
442        let res = async_with_vars(
443            [("SOLDEER_API_URL", Some(server.url()))],
444            get_latest_version("forge-std"),
445        )
446        .await;
447        assert!(res.is_ok(), "{res:?}");
448        assert_eq!(res.unwrap(), dependency);
449    }
450
451    #[tokio::test]
452    async fn test_get_all_versions_descending() {
453        let mut server = Server::new_async().await;
454        // data is not sorted in reverse semver order
455        let data = r#"{"data":[{"created_at":"2024-07-03T14:44:58.148723Z","deleted":false,"downloads":21,"id":"b463683a-c4b4-40bf-b707-1c4eb343c4d2","internal_name":"forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","version":"1.9.0"},{"created_at":"2024-08-06T17:31:25.751079Z","deleted":false,"downloads":3389,"id":"660132e6-4902-4804-8c4b-7cae0a648054","internal_name":"forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","version":"1.9.2"},{"created_at":"2024-07-03T14:44:59.729623Z","deleted":false,"downloads":5290,"id":"fa5160fc-ba7b-40fd-8e99-8becd6dadbe4","internal_name":"forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","version":"1.9.1"}],"status":"success"}"#;
456        server
457            .mock("GET", "/api/v1/revision")
458            .match_query(Matcher::Any)
459            .with_header("content-type", "application/json")
460            .with_body(data)
461            .create_async()
462            .await;
463
464        let res = async_with_vars(
465            [("SOLDEER_API_URL", Some(server.url()))],
466            get_all_versions_descending("forge-std"),
467        )
468        .await;
469        assert!(res.is_ok(), "{res:?}");
470        assert_eq!(
471            res.unwrap(),
472            Versions::Semver(vec![
473                "1.9.2".parse().unwrap(),
474                "1.9.1".parse().unwrap(),
475                "1.9.0".parse().unwrap()
476            ])
477        );
478    }
479
480    #[tokio::test]
481    async fn test_get_latest_supported_version_semver() {
482        let mut server = Server::new_async().await;
483        let data = r#"{"data":[{"created_at":"2024-08-06T17:31:25.751079Z","deleted":false,"downloads":3389,"id":"660132e6-4902-4804-8c4b-7cae0a648054","internal_name":"forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","version":"1.9.2"},{"created_at":"2024-07-03T14:44:59.729623Z","deleted":false,"downloads":5290,"id":"fa5160fc-ba7b-40fd-8e99-8becd6dadbe4","internal_name":"forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","version":"1.9.1"},{"created_at":"2024-07-03T14:44:58.148723Z","deleted":false,"downloads":21,"id":"b463683a-c4b4-40bf-b707-1c4eb343c4d2","internal_name":"forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","version":"1.9.0"}],"status":"success"}"#;
484        server
485            .mock("GET", "/api/v1/revision")
486            .match_query(Matcher::Any)
487            .with_header("content-type", "application/json")
488            .with_body(data)
489            .create_async()
490            .await;
491
492        let dependency: Dependency =
493            HttpDependency::builder().name("forge-std").version_req("^1.9.0").build().into();
494        let res = async_with_vars(
495            [("SOLDEER_API_URL", Some(server.url()))],
496            get_latest_supported_version(&dependency),
497        )
498        .await;
499        assert!(res.is_ok(), "{res:?}");
500        assert_eq!(res.unwrap(), "1.9.2");
501    }
502
503    #[tokio::test]
504    async fn test_get_latest_supported_version_no_semver() {
505        let mut server = Server::new_async().await;
506        let data = r#"{"data":[{"created_at":"2024-08-06T17:31:25.751079Z","deleted":false,"downloads":3389,"id":"660132e6-4902-4804-8c4b-7cae0a648054","internal_name":"forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","version":"2024-08"},{"created_at":"2024-07-03T14:44:59.729623Z","deleted":false,"downloads":5290,"id":"fa5160fc-ba7b-40fd-8e99-8becd6dadbe4","internal_name":"forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","version":"2024-07"},{"created_at":"2024-07-03T14:44:58.148723Z","deleted":false,"downloads":21,"id":"b463683a-c4b4-40bf-b707-1c4eb343c4d2","internal_name":"forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","version":"2024-06"}],"status":"success"}"#;
507        server
508            .mock("GET", "/api/v1/revision")
509            .match_query(Matcher::Any)
510            .with_header("content-type", "application/json")
511            .with_body(data)
512            .create_async()
513            .await;
514
515        let dependency: Dependency =
516            HttpDependency::builder().name("forge-std").version_req("2024-06").build().into();
517        let res = async_with_vars(
518            [("SOLDEER_API_URL", Some(server.url()))],
519            get_latest_supported_version(&dependency),
520        )
521        .await;
522        assert!(res.is_ok(), "{res:?}");
523        assert_eq!(res.unwrap(), "2024-06"); // should resolve to the exact match
524
525        let dependency: Dependency =
526            HttpDependency::builder().name("forge-std").version_req("non-existant").build().into();
527        let res = async_with_vars(
528            [("SOLDEER_API_URL", Some(server.url()))],
529            get_latest_supported_version(&dependency),
530        )
531        .await;
532        assert!(matches!(res, Err(RegistryError::NoMatchingVersion { .. })));
533    }
534
535    #[test]
536    fn test_parse_version_req() {
537        assert_eq!(parse_version_req("1.9.0"), Some(VersionReq::parse("=1.9.0").unwrap()));
538        assert_eq!(parse_version_req("=1.9.0"), Some(VersionReq::parse("=1.9.0").unwrap()));
539        assert_eq!(parse_version_req("^1.9.0"), Some(VersionReq::parse("^1.9.0").unwrap()));
540        assert_eq!(
541            parse_version_req("^1.9.0,^1.10.0"),
542            Some(VersionReq::parse("^1.9.0, ^1.10.0").unwrap())
543        );
544        assert_eq!(
545            parse_version_req("1.9.0,1.10.0"),
546            Some(VersionReq::parse("=1.9.0,=1.10.0").unwrap())
547        );
548        assert_eq!(parse_version_req(">=1.9.0"), Some(VersionReq::parse(">=1.9.0").unwrap()));
549        assert_eq!(parse_version_req(""), None);
550        assert_eq!(parse_version_req("foobar"), None);
551        assert_eq!(parse_version_req("*"), Some(VersionReq::STAR));
552    }
553}