1use chrono::{DateTime, Utc};
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4
5mod datetime_format_opt {
6 use chrono::{DateTime, Utc};
7 use serde::{self, Deserialize, Deserializer, Serializer};
8
9 const FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
10
11 pub fn serialize<S>(date: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
12 where
13 S: Serializer,
14 {
15 match date {
16 Some(d) => serializer.serialize_str(&d.format(FORMAT).to_string()),
17 None => serializer.serialize_none(),
18 }
19 }
20
21 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
22 where
23 D: Deserializer<'de>,
24 {
25 let opt: Option<String> = Option::deserialize(deserializer)?;
26 match opt {
27 Some(s) => s
28 .parse::<DateTime<Utc>>()
29 .map(Some)
30 .map_err(serde::de::Error::custom),
31 None => Ok(None),
32 }
33 }
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
41pub struct PackageLookup {
42 pub found: bool,
43 pub name: String,
44 pub registry: String,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub package: Option<PackageInfo>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
51pub struct PackageInfo {
52 pub name: String,
53 pub registry: String,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub latest_version: Option<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub description: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub license: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub homepage: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub repository: Option<String>,
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub authors: Vec<String>,
66 #[serde(skip_serializing_if = "Option::is_none", with = "datetime_format_opt")]
67 #[schemars(with = "Option<String>")]
68 pub updated_at: Option<DateTime<Utc>>,
69 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub keywords: Vec<String>,
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
72 pub classifiers: Vec<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub requires_python: Option<String>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
79pub struct VersionInfo {
80 pub version: String,
81 #[serde(skip_serializing_if = "Option::is_none", with = "datetime_format_opt")]
82 #[schemars(with = "Option<String>")]
83 pub released_at: Option<DateTime<Utc>>,
84 pub prerelease: bool,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
90pub struct VersionList {
91 pub found: bool,
92 pub name: String,
93 pub registry: String,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub latest: Option<String>,
96 #[serde(default, skip_serializing_if = "Vec::is_empty")]
97 pub versions: Vec<VersionInfo>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
104pub struct LatestVersion {
105 pub found: bool,
106 pub name: String,
107 pub registry: String,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub version: Option<VersionInfo>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
114pub struct SearchResult {
115 pub name: String,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 pub description: Option<String>,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub latest_version: Option<String>,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub downloads: Option<u64>,
122 #[serde(default, skip_serializing_if = "Vec::is_empty")]
123 pub keywords: Vec<String>,
124}
125
126#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
130#[serde(rename_all = "snake_case")]
131pub enum SearchSort {
132 #[default]
134 Relevance,
135 Popularity,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
143#[serde(untagged)]
144#[non_exhaustive]
145pub enum EolStatus {
146 Date(chrono::NaiveDate),
147 Bool(bool),
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
152pub struct RuntimeVersion {
153 pub cycle: String,
154 pub latest: String,
155 #[serde(skip_serializing_if = "Option::is_none")]
156 pub release_date: Option<chrono::NaiveDate>,
157 #[serde(skip_serializing_if = "Option::is_none")]
158 pub latest_release_date: Option<chrono::NaiveDate>,
159 pub eol: EolStatus,
160 pub lts: bool,
161 #[serde(skip_serializing_if = "Option::is_none")]
162 pub support: Option<EolStatus>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
167pub struct RuntimeInfo {
168 pub id: String,
169 pub name: String,
170 pub latest_stable: String,
171 pub latest_cycle: String,
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub changelog_url: Option<String>,
174 pub versions: Vec<RuntimeVersion>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
181pub struct ReleaseInfo {
182 pub tag: String,
183 #[serde(skip_serializing_if = "Option::is_none")]
184 pub name: Option<String>,
185 #[serde(skip_serializing_if = "Option::is_none", with = "datetime_format_opt")]
186 #[schemars(with = "Option<String>")]
187 pub published_at: Option<DateTime<Utc>>,
188 pub prerelease: bool,
189 pub draft: bool,
190 pub html_url: String,
191 #[serde(skip_serializing_if = "Vec::is_empty")]
192 pub assets: Vec<ReleaseAsset>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
197pub struct ReleaseAsset {
198 pub name: String,
199 pub size: u64,
200 pub download_url: String,
201 pub download_count: u64,
202 pub content_type: String,
203}
204
205#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
209#[serde(rename_all = "snake_case")]
210pub enum RepoSort {
211 #[default]
213 Stars,
214 Updated,
216 Relevance,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
222pub struct RepositorySearchResult {
223 pub forge: String,
224 pub full_name: String,
225 pub owner: String,
226 pub name: String,
227 #[serde(skip_serializing_if = "Option::is_none")]
228 pub description: Option<String>,
229 pub stars: u64,
230 #[serde(skip_serializing_if = "Option::is_none")]
231 pub language: Option<String>,
232 #[serde(default, skip_serializing_if = "Vec::is_empty")]
233 pub topics: Vec<String>,
234 pub url: String,
235 #[serde(skip_serializing_if = "Option::is_none", with = "datetime_format_opt")]
236 #[schemars(with = "Option<String>")]
237 pub updated_at: Option<DateTime<Utc>>,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
244pub struct ActionPin {
245 pub pinned: String,
246 pub action: String,
247 pub tag: String,
248 pub commit_sha: String,
249 #[serde(skip_serializing_if = "Option::is_none")]
250 pub url: Option<String>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
255pub struct TagPin {
256 pub owner: String,
257 pub repository: String,
258 pub forge_id: String,
259 pub tag: String,
260 pub commit_sha: String,
261 #[serde(skip_serializing_if = "Option::is_none")]
262 pub url: Option<String>,
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn repository_search_result_omits_empty_optionals() {
271 let r = RepositorySearchResult {
272 forge: "github".into(),
273 full_name: "owner/repo".into(),
274 owner: "owner".into(),
275 name: "repo".into(),
276 description: None,
277 stars: 42,
278 language: None,
279 topics: Vec::new(),
280 url: "https://github.com/owner/repo".into(),
281 updated_at: None,
282 };
283 let v = serde_json::to_value(&r).unwrap();
284 assert_eq!(v["stars"], 42);
285 assert_eq!(v["full_name"], "owner/repo");
286 assert!(v.get("description").is_none());
287 assert!(v.get("language").is_none());
288 assert!(v.get("topics").is_none());
289 assert!(v.get("updated_at").is_none());
290 }
291
292 #[test]
293 fn repo_sort_defaults_to_stars() {
294 assert!(matches!(RepoSort::default(), RepoSort::Stars));
295 }
296
297 #[test]
298 fn search_result_omits_empty_downloads_and_keywords() {
299 let r = SearchResult {
300 name: "requests".into(),
301 description: Some("HTTP for Humans".into()),
302 latest_version: Some("2.32.5".into()),
303 downloads: None,
304 keywords: Vec::new(),
305 };
306 let v = serde_json::to_value(&r).unwrap();
307 assert_eq!(v["name"], "requests");
308 assert!(v.get("downloads").is_none());
309 assert!(v.get("keywords").is_none());
310 }
311
312 #[test]
313 fn search_sort_defaults_to_relevance() {
314 assert!(matches!(SearchSort::default(), SearchSort::Relevance));
315 }
316
317 #[test]
318 fn version_list_not_found_omits_empty_fields() {
319 let list = VersionList {
320 found: false,
321 name: "nope".into(),
322 registry: "crates".into(),
323 latest: None,
324 versions: Vec::new(),
325 };
326 let v = serde_json::to_value(&list).unwrap();
327 assert_eq!(v["found"], false);
328 assert!(v.get("latest").is_none());
329 assert!(v.get("versions").is_none());
330 }
331
332 #[test]
333 fn latest_version_not_found_omits_version() {
334 let lv = LatestVersion {
335 found: false,
336 name: "nope".into(),
337 registry: "crates".into(),
338 version: None,
339 };
340 let v = serde_json::to_value(&lv).unwrap();
341 assert_eq!(v["found"], false);
342 assert!(v.get("version").is_none());
343 }
344}