Skip to main content

update_kit/checker/sources/
jsr.rs

1use crate::utils::http::{fetch_with_timeout, FetchOptions as HttpFetchOptions};
2
3use super::{FetchOptions, VersionInfo, VersionSource, VersionSourceResult};
4
5/// A version source backed by JSR (jsr.io).
6pub struct JsrSource {
7    scope: String,
8    name: String,
9    base_url: String,
10}
11
12impl JsrSource {
13    pub fn new(scope: String, name: String) -> Self {
14        Self {
15            scope,
16            name,
17            base_url: "https://jsr.io".to_string(),
18        }
19    }
20
21    pub fn with_base_url(scope: String, name: String, base_url: String) -> Self {
22        Self {
23            scope,
24            name,
25            base_url,
26        }
27    }
28}
29
30#[async_trait::async_trait]
31impl VersionSource for JsrSource {
32    fn name(&self) -> &str {
33        "jsr"
34    }
35
36    async fn fetch_latest(&self, _options: FetchOptions) -> VersionSourceResult {
37        let url = format!("{}/@{}/{}/meta.json", self.base_url, self.scope, self.name);
38
39        let response = match fetch_with_timeout(
40            &url,
41            Some(HttpFetchOptions {
42                timeout_ms: None,
43                headers: None,
44            }),
45        )
46        .await
47        {
48            Ok(r) => r,
49            Err(e) => {
50                return VersionSourceResult::Error {
51                    reason: e.to_string(),
52                    status: None,
53                }
54            }
55        };
56
57        let status = response.status().as_u16();
58
59        if !response.status().is_success() {
60            return VersionSourceResult::Error {
61                reason: format!("JSR returned status {}", status),
62                status: Some(status),
63            };
64        }
65
66        let json: serde_json::Value = match response.json().await {
67            Ok(j) => j,
68            Err(e) => {
69                return VersionSourceResult::Error {
70                    reason: format!("Failed to parse response: {}", e),
71                    status: Some(status),
72                }
73            }
74        };
75
76        // JSR meta.json has a "latest" field or we look in "versions" for the latest
77        let version = json
78            .get("latest")
79            .and_then(|v| v.as_str())
80            .map(String::from)
81            .or_else(|| {
82                // Fallback: find the latest from the versions object
83                json.get("versions")
84                    .and_then(|v| v.as_object())
85                    .and_then(|obj| obj.keys().next_back().cloned())
86            });
87
88        match version {
89            Some(version) => VersionSourceResult::Found {
90                info: VersionInfo {
91                    version,
92                    release_url: Some(format!(
93                        "{}/@{}/{}",
94                        self.base_url, self.scope, self.name
95                    )),
96                    release_notes: None,
97                    assets: None,
98                    published_at: None,
99                },
100                etag: None,
101            },
102            None => VersionSourceResult::Error {
103                reason: "Could not determine latest version from JSR meta.json".into(),
104                status: Some(status),
105            },
106        }
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn test_source_name() {
116        let source = JsrSource::new("scope".into(), "pkg".into());
117        assert_eq!(source.name(), "jsr");
118    }
119
120    #[test]
121    fn default_base_url() {
122        let source = JsrSource::new("scope".into(), "pkg".into());
123        assert_eq!(source.base_url, "https://jsr.io");
124    }
125
126    #[test]
127    fn custom_base_url() {
128        let source = JsrSource::with_base_url(
129            "scope".into(),
130            "pkg".into(),
131            "https://custom.jsr.io".into(),
132        );
133        assert_eq!(source.base_url, "https://custom.jsr.io");
134    }
135
136    #[tokio::test]
137    async fn fetch_latest_unreachable_returns_error() {
138        let source = JsrSource::with_base_url(
139            "scope".into(),
140            "pkg".into(),
141            "https://localhost:1".into(),
142        );
143        let result = source.fetch_latest(FetchOptions::default()).await;
144        match result {
145            VersionSourceResult::Error { reason, .. } => {
146                assert!(!reason.is_empty());
147            }
148            other => panic!("Expected Error, got: {other:?}"),
149        }
150    }
151
152    #[tokio::test]
153    async fn fetch_latest_http_rejected() {
154        let source = JsrSource::with_base_url(
155            "scope".into(),
156            "pkg".into(),
157            "http://insecure.com".into(),
158        );
159        let result = source.fetch_latest(FetchOptions::default()).await;
160        match result {
161            VersionSourceResult::Error { reason, .. } => {
162                assert!(
163                    reason.contains("HTTPS") || reason.contains("Insecure"),
164                    "Expected HTTPS/Insecure error, got: {reason}"
165                );
166            }
167            other => panic!("Expected Error for HTTP, got: {other:?}"),
168        }
169    }
170
171    #[tokio::test]
172    async fn fetch_versions_returns_unsupported() {
173        let source = JsrSource::new("scope".into(), "pkg".into());
174        let result = source
175            .fetch_versions(super::super::FetchVersionsOptions::default())
176            .await;
177        assert!(result.is_err());
178        let err = result.unwrap_err();
179        assert_eq!(err.code(), "UNSUPPORTED_OPERATION");
180    }
181
182    #[test]
183    fn source_name_is_jsr() {
184        let source = JsrSource::with_base_url("s".into(), "n".into(), "https://x.com".into());
185        assert_eq!(source.name(), "jsr");
186    }
187
188    #[test]
189    fn scope_and_name_stored() {
190        let source = JsrSource::new("my-scope".into(), "my-pkg".into());
191        assert_eq!(source.scope, "my-scope");
192        assert_eq!(source.name, "my-pkg");
193    }
194}