Skip to main content

update_kit/checker/sources/
custom_manifest.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 a custom JSON manifest URL.
6pub struct CustomManifestSource {
7    url: String,
8    version_field: String,
9}
10
11impl CustomManifestSource {
12    pub fn new(url: String, version_field: Option<String>) -> Self {
13        Self {
14            url,
15            version_field: version_field.unwrap_or_else(|| "version".to_string()),
16        }
17    }
18
19    /// Extract a value from a JSON object using dot-notation field path.
20    /// E.g., "data.latest.version" navigates json["data"]["latest"]["version"].
21    fn extract_field<'a>(json: &'a serde_json::Value, field_path: &str) -> Option<&'a str> {
22        let mut current = json;
23        for part in field_path.split('.') {
24            current = current.get(part)?;
25        }
26        current.as_str()
27    }
28}
29
30#[async_trait::async_trait]
31impl VersionSource for CustomManifestSource {
32    fn name(&self) -> &str {
33        "custom"
34    }
35
36    async fn fetch_latest(&self, _options: FetchOptions) -> VersionSourceResult {
37        let response = match fetch_with_timeout(
38            &self.url,
39            Some(HttpFetchOptions {
40                timeout_ms: None,
41                headers: None,
42            }),
43        )
44        .await
45        {
46            Ok(r) => r,
47            Err(e) => {
48                return VersionSourceResult::Error {
49                    reason: e.to_string(),
50                    status: None,
51                }
52            }
53        };
54
55        let status = response.status().as_u16();
56
57        if !response.status().is_success() {
58            return VersionSourceResult::Error {
59                reason: format!("Custom manifest returned status {}", status),
60                status: Some(status),
61            };
62        }
63
64        let json: serde_json::Value = match response.json().await {
65            Ok(j) => j,
66            Err(e) => {
67                return VersionSourceResult::Error {
68                    reason: format!("Failed to parse response: {}", e),
69                    status: Some(status),
70                }
71            }
72        };
73
74        match Self::extract_field(&json, &self.version_field) {
75            Some(version) => VersionSourceResult::Found {
76                info: VersionInfo {
77                    version: version.to_string(),
78                    release_url: None,
79                    release_notes: None,
80                    assets: None,
81                    published_at: None,
82                },
83                etag: None,
84            },
85            None => VersionSourceResult::Error {
86                reason: format!(
87                    "Field '{}' not found in manifest response",
88                    self.version_field
89                ),
90                status: Some(status),
91            },
92        }
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn test_source_name() {
102        let source = CustomManifestSource::new(
103            "https://example.com/version.json".into(),
104            None,
105        );
106        assert_eq!(source.name(), "custom");
107    }
108
109    #[test]
110    fn test_default_version_field() {
111        let source = CustomManifestSource::new(
112            "https://example.com/version.json".into(),
113            None,
114        );
115        assert_eq!(source.version_field, "version");
116    }
117
118    #[test]
119    fn test_extract_field_simple() {
120        let json = serde_json::json!({"version": "1.2.3"});
121        assert_eq!(
122            CustomManifestSource::extract_field(&json, "version"),
123            Some("1.2.3")
124        );
125    }
126
127    #[test]
128    fn test_extract_field_nested() {
129        let json = serde_json::json!({
130            "data": {
131                "latest": {
132                    "version": "2.0.0"
133                }
134            }
135        });
136        assert_eq!(
137            CustomManifestSource::extract_field(&json, "data.latest.version"),
138            Some("2.0.0")
139        );
140    }
141
142    #[test]
143    fn test_extract_field_missing() {
144        let json = serde_json::json!({"name": "app"});
145        assert_eq!(
146            CustomManifestSource::extract_field(&json, "version"),
147            None
148        );
149    }
150
151    #[test]
152    fn custom_version_field() {
153        let source = CustomManifestSource::new(
154            "https://example.com/v.json".into(),
155            Some("data.version".into()),
156        );
157        assert_eq!(source.version_field, "data.version");
158    }
159
160    #[test]
161    fn extract_field_deeply_nested() {
162        let json = serde_json::json!({"a": {"b": {"c": {"d": "1.0.0"}}}});
163        assert_eq!(
164            CustomManifestSource::extract_field(&json, "a.b.c.d"),
165            Some("1.0.0")
166        );
167    }
168
169    #[test]
170    fn extract_field_non_string_value() {
171        let json = serde_json::json!({"version": 123});
172        assert_eq!(
173            CustomManifestSource::extract_field(&json, "version"),
174            None
175        );
176    }
177
178    #[test]
179    fn extract_field_null_value() {
180        let json = serde_json::json!({"version": null});
181        assert_eq!(
182            CustomManifestSource::extract_field(&json, "version"),
183            None
184        );
185    }
186
187    #[test]
188    fn extract_field_array_value() {
189        let json = serde_json::json!({"version": ["1.0.0"]});
190        assert_eq!(
191            CustomManifestSource::extract_field(&json, "version"),
192            None
193        );
194    }
195
196    #[tokio::test]
197    async fn fetch_latest_http_url_rejected() {
198        let source =
199            CustomManifestSource::new("http://insecure.com/version.json".into(), None);
200        let result = source.fetch_latest(FetchOptions::default()).await;
201        match result {
202            VersionSourceResult::Error { reason, .. } => {
203                assert!(reason.contains("HTTPS") || reason.contains("Insecure"));
204            }
205            other => panic!("Expected Error, got: {other:?}"),
206        }
207    }
208
209    #[tokio::test]
210    async fn fetch_latest_unreachable_returns_error() {
211        let source =
212            CustomManifestSource::new("https://localhost:1/version.json".into(), None);
213        let result = source.fetch_latest(FetchOptions::default()).await;
214        match result {
215            VersionSourceResult::Error { reason, .. } => {
216                assert!(!reason.is_empty());
217            }
218            other => panic!("Expected Error, got: {other:?}"),
219        }
220    }
221
222    #[tokio::test]
223    async fn fetch_versions_returns_unsupported() {
224        let source =
225            CustomManifestSource::new("https://example.com/v.json".into(), None);
226        let result = source
227            .fetch_versions(super::super::FetchVersionsOptions::default())
228            .await;
229        assert!(result.is_err());
230        assert_eq!(result.unwrap_err().code(), "UNSUPPORTED_OPERATION");
231    }
232}