1use crate::packages::{archive::PackageArchive, manifest::PackageManifest};
2use serde::Deserialize;
3use serde_json::Value as JsonValue;
4use std::{
5 env, fs, io,
6 path::{Path, PathBuf},
7 process::{Command, Output},
8 time::{SystemTime, UNIX_EPOCH},
9};
10use thiserror::Error;
11
12pub const DEFAULT_BASE_URL: &str = "https://lust-lang.dev/";
13
14#[derive(Debug, Error)]
15pub enum RegistryError {
16 #[error("failed to execute curl command: {0}")]
17 Spawn(#[from] io::Error),
18
19 #[error("registry request failed with status {status}: {stderr}")]
20 CommandFailed { status: i32, stderr: String },
21
22 #[error("registry responded with status {status}: {message}")]
23 Api {
24 status: u16,
25 code: Option<String>,
26 message: String,
27 },
28
29 #[error("failed to parse registry response: {0}")]
30 Json(#[from] serde_json::Error),
31}
32
33#[derive(Debug, Clone)]
34pub struct RegistryClient {
35 base_url: String,
36}
37
38#[derive(Debug, Clone, Deserialize)]
39pub struct PublishResponse {
40 pub package: String,
41 pub version: String,
42 pub artifact_sha256: String,
43 pub download_url: String,
44}
45
46#[derive(Debug, Clone, Deserialize)]
47pub struct PackageSearchResponse {
48 pub total: u64,
49 pub page: u32,
50 pub per_page: u32,
51 pub sort: String,
52 pub packages: Vec<PackageSummary>,
53}
54
55#[derive(Debug, Clone, Deserialize)]
56pub struct PackageSummary {
57 pub name: String,
58 pub description: Option<String>,
59 #[serde(default)]
60 pub keywords: Vec<String>,
61 #[serde(default)]
62 pub categories: Vec<String>,
63 pub downloads: Option<u64>,
64 pub updated_at: Option<String>,
65 pub latest_version: Option<PackageVersionInfo>,
66}
67
68#[derive(Debug, Clone, Deserialize)]
69pub struct PackageDetails {
70 pub name: String,
71 pub description: Option<String>,
72 #[serde(default)]
73 pub keywords: Vec<String>,
74 #[serde(default)]
75 pub categories: Vec<String>,
76 pub downloads: Option<u64>,
77 pub updated_at: Option<String>,
78 pub latest_version: Option<PackageVersionInfo>,
79 #[serde(default)]
80 pub versions: Vec<PackageVersionInfo>,
81 #[serde(default)]
82 pub readme_html: Option<String>,
83}
84
85#[derive(Debug, Clone, Deserialize)]
86pub struct PackageVersionInfo {
87 pub version: String,
88 pub published_at: Option<String>,
89}
90
91#[derive(Debug, Default)]
92pub struct SearchParameters {
93 pub q: Option<String>,
94 pub keyword: Option<String>,
95 pub category: Option<String>,
96 pub sort: Option<String>,
97 pub page: Option<u32>,
98 pub per_page: Option<u32>,
99}
100
101#[derive(Debug)]
102pub struct DownloadedArchive {
103 path: PathBuf,
104}
105
106impl DownloadedArchive {
107 pub fn path(&self) -> &Path {
108 &self.path
109 }
110
111 pub fn into_path(self) -> PathBuf {
112 let path = self.path.clone();
113 std::mem::forget(self);
114 path
115 }
116}
117
118impl Drop for DownloadedArchive {
119 fn drop(&mut self) {
120 let _ = fs::remove_file(&self.path);
121 }
122}
123
124impl RegistryClient {
125 pub fn new(base: &str) -> Result<Self, RegistryError> {
126 let base_url = if base.ends_with('/') {
127 base.to_string()
128 } else {
129 format!("{base}/")
130 };
131 Ok(Self { base_url })
132 }
133
134 pub fn publish(
135 &self,
136 manifest: &PackageManifest,
137 token: &str,
138 archive: &PackageArchive,
139 readme: Option<&str>,
140 ) -> Result<PublishResponse, RegistryError> {
141 let metadata_json = serde_json::to_string(&manifest.metadata_payload())?;
142 let metadata_file = TempFile::write("lust-metadata", "json", metadata_json.as_bytes())?;
143 let readme_file = if let Some(content) = readme {
144 if content.trim().is_empty() {
145 None
146 } else {
147 Some(TempFile::write("lust-readme", "md", content.as_bytes())?)
148 }
149 } else {
150 None
151 };
152
153 let response_file = TempFile::empty("lust-response", "json")?;
154 let mut command = self.base_curl_command();
155 command.arg("--output").arg(response_file.path());
156 command.arg("--write-out").arg("%{http_code}");
157 command.arg("-X").arg("POST");
158 command.arg(self.join("api/publish"));
159 command
160 .arg("-H")
161 .arg(format!("Authorization: Bearer {}", token));
162 command.arg("-F").arg(format!(
163 "metadata=@{};type=application/json",
164 metadata_file.path().display()
165 ));
166 command.arg("-F").arg(format!(
167 "artifact=@{};type=application/gzip",
168 archive.path().display()
169 ));
170 if let Some(readme_temp) = &readme_file {
171 command
172 .arg("-F")
173 .arg(format!("readme=@{}", readme_temp.path().display()));
174 }
175
176 let output = command.output()?;
177 let status_code = parse_status_code(&output)?;
178 if !output.status.success() {
179 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
180 return Err(RegistryError::CommandFailed {
181 status: output.status.code().unwrap_or(-1),
182 stderr,
183 });
184 }
185 let body = response_file.read_to_string()?;
186 if status_code >= 400 {
187 return Err(parse_api_error(status_code, &body));
188 }
189 let response = serde_json::from_str(&body)?;
190 Ok(response)
191 }
192
193 pub fn package_details(&self, name: &str) -> Result<PackageDetails, RegistryError> {
194 let url = self.join(&format!("api/packages/{}", encode_segment(name)));
195 let (status, body) = self.get_json(&url)?;
196 if status >= 400 {
197 return Err(parse_api_error(status, &body));
198 }
199 Ok(serde_json::from_str(&body)?)
200 }
201
202 pub fn search_packages(
203 &self,
204 params: &SearchParameters,
205 ) -> Result<PackageSearchResponse, RegistryError> {
206 let mut url = self.join("api/packages");
207 let mut query: Vec<(String, String)> = Vec::new();
208 if let Some(q) = ¶ms.q {
209 query.push(("q".to_string(), encode_query_value(q)));
210 }
211 if let Some(keyword) = ¶ms.keyword {
212 query.push(("keyword".to_string(), encode_query_value(keyword)));
213 }
214 if let Some(category) = ¶ms.category {
215 query.push(("category".to_string(), encode_query_value(category)));
216 }
217 if let Some(sort) = ¶ms.sort {
218 query.push(("sort".to_string(), encode_query_value(sort)));
219 }
220 if let Some(page) = params.page {
221 query.push(("page".to_string(), page.to_string()));
222 }
223 if let Some(per_page) = params.per_page {
224 query.push(("per_page".to_string(), per_page.to_string()));
225 }
226 if !query.is_empty() {
227 let query_string = query
228 .into_iter()
229 .map(|(k, v)| format!("{k}={v}"))
230 .collect::<Vec<_>>()
231 .join("&");
232 url.push('?');
233 url.push_str(&query_string);
234 }
235 let (status, body) = self.get_json(&url)?;
236 if status >= 400 {
237 return Err(parse_api_error(status, &body));
238 }
239 Ok(serde_json::from_str(&body)?)
240 }
241
242 pub fn download_package(
243 &self,
244 name: &str,
245 version: &str,
246 ) -> Result<DownloadedArchive, RegistryError> {
247 let url = self.join(&format!(
248 "api/packages/{}/{}/download",
249 encode_segment(name),
250 encode_segment(version)
251 ));
252 let output_path = temp_path("lust-download", "tar.gz");
253 let mut command = self.base_curl_command();
254 command.arg("--output").arg(&output_path);
255 command.arg("--write-out").arg("%{http_code}");
256 command.arg(url);
257 let output = command.output()?;
258 let status_code = parse_status_code(&output)?;
259 if !output.status.success() {
260 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
261 return Err(RegistryError::CommandFailed {
262 status: output.status.code().unwrap_or(-1),
263 stderr,
264 });
265 }
266 if status_code >= 400 {
267 let body = fs::read_to_string(&output_path).unwrap_or_default();
268 fs::remove_file(&output_path).ok();
269 return Err(parse_api_error(status_code, &body));
270 }
271 Ok(DownloadedArchive { path: output_path })
272 }
273
274 fn get_json(&self, url: &str) -> Result<(u16, String), RegistryError> {
275 let response_file = TempFile::empty("lust-response", "json")?;
276 let mut command = self.base_curl_command();
277 command.arg("--output").arg(response_file.path());
278 command.arg("--write-out").arg("%{http_code}");
279 command.arg(url);
280 let output = command.output()?;
281 let status_code = parse_status_code(&output)?;
282 if !output.status.success() {
283 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
284 return Err(RegistryError::CommandFailed {
285 status: output.status.code().unwrap_or(-1),
286 stderr,
287 });
288 }
289 let body = response_file.read_to_string()?;
290 Ok((status_code, body))
291 }
292
293 fn base_curl_command(&self) -> Command {
294 let mut command = Command::new(resolve_curl_command());
295 command.arg("-sS");
296 command
297 }
298
299 fn join(&self, path: &str) -> String {
300 format!("{}{}", self.base_url, path)
301 }
302}
303
304fn resolve_curl_command() -> &'static str {
305 #[cfg(target_os = "windows")]
306 {
307 "curl.exe"
308 }
309 #[cfg(not(target_os = "windows"))]
310 {
311 "curl"
312 }
313}
314
315fn encode_segment(input: &str) -> String {
316 const UNRESERVED: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
317 const HEX: &[u8] = b"0123456789ABCDEF";
318 let mut encoded = String::new();
319 for byte in input.bytes() {
320 if UNRESERVED.contains(&byte) {
321 encoded.push(byte as char);
322 } else {
323 encoded.push('%');
324 encoded.push(HEX[(byte >> 4) as usize] as char);
325 encoded.push(HEX[(byte & 0xF) as usize] as char);
326 }
327 }
328 encoded
329}
330
331fn encode_query_value(input: &str) -> String {
332 encode_segment(input)
333}
334
335fn parse_status_code(output: &Output) -> Result<u16, RegistryError> {
336 let status_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
337 status_str
338 .parse::<u16>()
339 .map_err(|_| RegistryError::CommandFailed {
340 status: output.status.code().unwrap_or(-1),
341 stderr: format!("invalid status code from curl: {}", status_str),
342 })
343}
344
345fn parse_api_error(status: u16, body: &str) -> RegistryError {
346 if let Ok(json) = serde_json::from_str::<JsonValue>(body) {
347 let code = json.get("code").and_then(|v| v.as_str()).map(String::from);
348 let message = json
349 .get("message")
350 .and_then(|v| v.as_str())
351 .map(String::from)
352 .filter(|m| !m.is_empty())
353 .unwrap_or_else(|| body.trim().to_string());
354 RegistryError::Api {
355 status,
356 code,
357 message,
358 }
359 } else {
360 RegistryError::Api {
361 status,
362 code: None,
363 message: body.trim().to_string(),
364 }
365 }
366}
367
368struct TempFile {
369 path: PathBuf,
370}
371
372impl TempFile {
373 fn write(prefix: &str, ext: &str, contents: &[u8]) -> io::Result<Self> {
374 let path = temp_path(prefix, ext);
375 fs::write(&path, contents)?;
376 Ok(Self { path })
377 }
378
379 fn empty(prefix: &str, ext: &str) -> io::Result<Self> {
380 let path = temp_path(prefix, ext);
381 Ok(Self { path })
382 }
383
384 fn path(&self) -> &Path {
385 &self.path
386 }
387
388 fn read_to_string(&self) -> io::Result<String> {
389 fs::read_to_string(&self.path)
390 }
391}
392
393impl Drop for TempFile {
394 fn drop(&mut self) {
395 let _ = fs::remove_file(&self.path);
396 }
397}
398
399fn temp_path(prefix: &str, ext: &str) -> PathBuf {
400 let mut path = env::temp_dir();
401 let timestamp = SystemTime::now()
402 .duration_since(UNIX_EPOCH)
403 .unwrap_or_default()
404 .as_micros();
405 let pid = std::process::id();
406 path.push(format!("{prefix}-{pid}-{timestamp}.{ext}"));
407 path
408}