Skip to main content

lust/packages/
registry.rs

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) = &params.q {
209            query.push(("q".to_string(), encode_query_value(q)));
210        }
211        if let Some(keyword) = &params.keyword {
212            query.push(("keyword".to_string(), encode_query_value(keyword)));
213        }
214        if let Some(category) = &params.category {
215            query.push(("category".to_string(), encode_query_value(category)));
216        }
217        if let Some(sort) = &params.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}