Skip to main content

facto_core/
models.rs

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// --- Registry types ---
37
38/// Result of a package lookup: the metadata when the package exists, or a
39/// not-found marker meaning the name is free on this registry.
40#[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/// Package metadata from a registry.
50#[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/// A single published version with release date.
78#[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/// Result of a version listing: the versions when the package exists, or a
88/// not-found marker meaning the name is free on this registry.
89#[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/// Result of a latest-stable-version lookup: the version when one exists, or a
101/// not-found marker. `found: true` with `version: None` means the package
102/// exists but publishes no stable (non-prerelease) release.
103#[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/// A search result entry from a registry.
113#[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/// Sort order for package search. Best-effort: popularity sorts the
127/// returned page by `downloads` (descending), leaving entries without a
128/// download count last.
129#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
130#[serde(rename_all = "snake_case")]
131pub enum SearchSort {
132    /// Registry-native relevance to the query (default).
133    #[default]
134    Relevance,
135    /// Most downloads first (best-effort over the returned page).
136    Popularity,
137}
138
139// --- Runtime types ---
140
141/// End-of-life status: either a date or a boolean flag.
142#[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/// A runtime version cycle with EOL status.
151#[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/// Runtime lifecycle info from endoflife.date.
166#[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// --- Release types ---
178
179/// A release from a forge repository.
180#[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/// A downloadable asset attached to a release.
196#[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// --- Forge search types ---
206
207/// Sort order for forge repository search. Best-effort per forge.
208#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
209#[serde(rename_all = "snake_case")]
210pub enum RepoSort {
211    /// Most stars first (default).
212    #[default]
213    Stars,
214    /// Most recently updated first.
215    Updated,
216    /// Forge-native relevance to the query.
217    Relevance,
218}
219
220/// A repository/project search hit from a code forge.
221#[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// --- Tag pin types ---
241
242/// A pinned GitHub Action: the ready-to-paste reference plus its parts.
243#[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/// Resolved tag reference with full commit SHA for secure pinning.
254#[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}