1use serde::{Deserialize, Serialize};
13
14use crate::http::{get_json, JsonGetError};
15use crate::url::encode_uri_component;
16
17pub const GITLAB_BASE: &str = "https://gitlab.com";
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct GitLabPackageFile {
24 pub version: String,
26 pub filename: String,
27 pub url: String,
29 pub size: u64,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub sha256: Option<String>,
33 pub created_at: String,
35}
36
37#[derive(Debug, thiserror::Error)]
38pub enum GitLabError {
39 #[error(transparent)]
41 Fetch(#[from] JsonGetError),
42 #[error("Package '{package}' has no version '{version}' in {project}. Available: {available}")]
43 NoVersion {
44 package: String,
45 version: String,
46 project: String,
47 available: String,
49 },
50 #[error("Package {project}/{package}@{version} has no {wanted}")]
51 NoFile {
52 project: String,
53 package: String,
54 version: String,
55 wanted: String,
57 },
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum FileSelector<'a> {
63 Named(&'a str),
65 Suffix(&'a str),
68}
69
70#[derive(Deserialize)]
71pub(crate) struct PackageWire {
72 id: u64,
73 name: String,
74 version: String,
75 package_type: String,
76 status: String,
77 created_at: String,
78}
79
80#[derive(Deserialize)]
81pub(crate) struct PackageFileWire {
82 file_name: String,
83 size: u64,
84 #[serde(default)]
85 file_sha256: Option<String>,
86 created_at: String,
87}
88
89fn newest<T>(items: impl IntoIterator<Item = T>, created_at: impl Fn(&T) -> &str) -> Option<T> {
91 items.into_iter().reduce(|latest, item| {
92 if created_at(&item) > created_at(&latest) {
93 item
94 } else {
95 latest
96 }
97 })
98}
99
100pub fn resolve_gitlab_package_file(
106 base: &str,
107 project: &str,
108 package: &str,
109 version: &str,
110 file: FileSelector<'_>,
111 token: Option<&str>,
112) -> Result<GitLabPackageFile, GitLabError> {
113 let base = base.trim_end_matches('/');
114 let project_segment = encode_uri_component(project);
116 let headers: Vec<(&str, &str)> = token
117 .map(|token| ("PRIVATE-TOKEN", token))
118 .into_iter()
119 .collect();
120
121 let listed: Vec<PackageWire> = get_json(
122 &format!(
123 "{base}/api/v4/projects/{project_segment}/packages?package_type=generic&package_name={}&per_page=100",
124 encode_uri_component(package)
125 ),
126 &headers,
127 )?;
128 let packages: Vec<PackageWire> = listed
132 .into_iter()
133 .filter(|p| p.name == package && p.package_type == "generic" && p.status == "default")
134 .collect();
135
136 let mut available: Vec<&str> = Vec::new();
137 for candidate in &packages {
138 if !available.contains(&candidate.version.as_str()) {
139 available.push(&candidate.version);
140 }
141 }
142 let available = if available.is_empty() {
143 "(none)".to_owned()
144 } else {
145 available.into_iter().take(8).collect::<Vec<_>>().join(", ")
146 };
147
148 let found = newest(packages.into_iter().filter(|p| p.version == version), |p| {
149 &p.created_at
150 })
151 .ok_or_else(|| GitLabError::NoVersion {
152 package: package.to_owned(),
153 version: version.to_owned(),
154 project: project.to_owned(),
155 available,
156 })?;
157
158 let files: Vec<PackageFileWire> = get_json(
159 &format!(
160 "{base}/api/v4/projects/{project_segment}/packages/{}/package_files?per_page=100",
161 found.id
162 ),
163 &headers,
164 )?;
165 let chosen = newest(
166 files.into_iter().filter(|f| match file {
167 FileSelector::Named(name) => f.file_name == name,
168 FileSelector::Suffix(suffix) => f.file_name.ends_with(suffix),
169 }),
170 |f| &f.created_at,
171 )
172 .ok_or_else(|| GitLabError::NoFile {
173 project: project.to_owned(),
174 package: package.to_owned(),
175 version: found.version.clone(),
176 wanted: match file {
177 FileSelector::Named(name) => format!("file '{name}'"),
178 FileSelector::Suffix(suffix) => format!("{suffix} file"),
179 },
180 })?;
181
182 Ok(GitLabPackageFile {
183 url: format!(
184 "{base}/api/v4/projects/{project_segment}/packages/generic/{}/{}/{}",
185 encode_uri_component(package),
186 encode_uri_component(&found.version),
187 encode_uri_component(&chosen.file_name),
188 ),
189 version: found.version,
190 filename: chosen.file_name,
191 size: chosen.size,
192 sha256: chosen.file_sha256,
193 created_at: found.created_at,
194 })
195}