Skip to main content

shipper_registry/
http.rs

1//! Lightweight HTTP-only registry client.
2//!
3//! This module provides [`HttpRegistryClient`] — a thin reqwest wrapper that
4//! takes a bare base-URL string. Intended for callers that do not have a full
5//! [`shipper_types::Registry`] handy (e.g. the parallel engine helper crate).
6//! For the complete registry surface, use the [`crate::HttpRegistryClient`] from
7//! the [`crate::context`] module.
8
9use std::time::Duration;
10
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13
14use crate::{CRATES_IO_API, DEFAULT_TIMEOUT_SECS, USER_AGENT, sparse_index_path};
15
16/// Lightweight HTTP registry client that operates on a raw base-URL.
17///
18/// Use [`crate::HttpRegistryClient`] for the full `Registry`-aware client with
19/// sparse-index visibility checks, readiness backoff, etc.
20#[derive(Debug, Clone)]
21pub struct HttpRegistryClient {
22    base_url: String,
23    timeout: Duration,
24    client: reqwest::blocking::Client,
25    cache_dir: Option<std::path::PathBuf>,
26}
27
28impl HttpRegistryClient {
29    /// Create a new registry client for the given base URL
30    pub fn new(base_url: &str) -> Self {
31        let client = reqwest::blocking::Client::builder()
32            .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
33            .user_agent(USER_AGENT)
34            .build()
35            .unwrap_or_else(|_| reqwest::blocking::Client::new());
36
37        Self {
38            base_url: base_url.trim_end_matches('/').to_string(),
39            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
40            client,
41            cache_dir: None,
42        }
43    }
44
45    /// Set the cache directory for sparse index fragments
46    pub fn with_cache_dir(mut self, cache_dir: std::path::PathBuf) -> Self {
47        self.cache_dir = Some(cache_dir);
48        self
49    }
50
51    /// Create a client for crates.io
52    pub fn crates_io() -> Self {
53        Self::new(CRATES_IO_API)
54    }
55
56    /// Set the request timeout
57    pub fn with_timeout(mut self, timeout: Duration) -> Self {
58        self.timeout = timeout;
59        self.client = reqwest::blocking::Client::builder()
60            .timeout(timeout)
61            .user_agent(USER_AGENT)
62            .build()
63            .unwrap_or_else(|_| reqwest::blocking::Client::new());
64        self
65    }
66
67    /// Check if a crate exists in the registry
68    pub fn crate_exists(&self, name: &str) -> Result<bool> {
69        let url = format!("{}/api/v1/crates/{}", self.base_url, name);
70
71        let response = self
72            .client
73            .get(&url)
74            .send()
75            .context("failed to send request to registry")?;
76
77        match response.status() {
78            reqwest::StatusCode::OK => Ok(true),
79            reqwest::StatusCode::NOT_FOUND => Ok(false),
80            status => Err(anyhow::anyhow!("unexpected status code: {}", status)),
81        }
82    }
83
84    /// Check if a specific version of a crate exists
85    pub fn version_exists(&self, name: &str, version: &str) -> Result<bool> {
86        let url = format!("{}/api/v1/crates/{}/{}", self.base_url, name, version);
87
88        let response = self
89            .client
90            .get(&url)
91            .send()
92            .context("failed to send request to registry")?;
93
94        match response.status() {
95            reqwest::StatusCode::OK => Ok(true),
96            reqwest::StatusCode::NOT_FOUND => Ok(false),
97            status => Err(anyhow::anyhow!("unexpected status code: {}", status)),
98        }
99    }
100
101    /// Get crate information
102    pub fn get_crate_info(&self, name: &str) -> Result<Option<CrateInfo>> {
103        let url = format!("{}/api/v1/crates/{}", self.base_url, name);
104
105        let response = self
106            .client
107            .get(&url)
108            .send()
109            .context("failed to send request to registry")?;
110
111        if response.status() == reqwest::StatusCode::NOT_FOUND {
112            return Ok(None);
113        }
114
115        if !response.status().is_success() {
116            return Err(anyhow::anyhow!(
117                "unexpected status code: {}",
118                response.status()
119            ));
120        }
121
122        let crate_response: CrateResponse =
123            response.json().context("failed to parse crate response")?;
124
125        Ok(Some(CrateInfo {
126            name: crate_response.crate_data.name,
127            newest_version: crate_response.crate_data.newest_version,
128            created_at: crate_response.crate_data.created_at,
129            updated_at: crate_response.crate_data.updated_at,
130        }))
131    }
132
133    fn fetch_owners_with_token(
134        &self,
135        name: &str,
136        token: Option<&str>,
137    ) -> Result<Option<OwnersResponse>> {
138        let url = format!("{}/api/v1/crates/{}/owners", self.base_url, name);
139        let mut request = self.client.get(&url);
140        if let Some(token) = token {
141            request = request.header("Authorization", token);
142        }
143
144        let response = request.send().context("failed to query owners")?;
145        match response.status() {
146            reqwest::StatusCode::OK => {
147                let owners_response: OwnersResponse =
148                    response.json().context("failed to parse owners response")?;
149                Ok(Some(owners_response))
150            }
151            reqwest::StatusCode::NOT_FOUND => Ok(None),
152            reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::UNAUTHORIZED => {
153                Err(anyhow::anyhow!(
154                    "forbidden when querying owners; token may be invalid or missing required scope"
155                ))
156            }
157            status => Err(anyhow::anyhow!(
158                "unexpected status while querying owners: {status}"
159            )),
160        }
161    }
162
163    /// Get the list of owners for a crate.
164    pub fn get_owners(&self, name: &str) -> Result<Vec<Owner>> {
165        let owners_response = self
166            .fetch_owners_with_token(name, None)?
167            .unwrap_or_default();
168        Ok(owners_response
169            .users
170            .into_iter()
171            .map(|owner| Owner {
172                login: owner.login,
173                name: owner.name,
174                avatar: owner.avatar,
175            })
176            .collect())
177    }
178
179    /// List owners for a crate with token-aware lookup.
180    pub fn list_owners(&self, name: &str, token: &str) -> Result<OwnersResponse> {
181        self.fetch_owners_with_token(name, Some(token))?
182            .ok_or_else(|| anyhow::anyhow!("crate not found when querying owners: {name}"))
183    }
184
185    /// Check if a user is an owner of a crate
186    pub fn is_owner(&self, name: &str, username: &str) -> Result<bool> {
187        let owners = self.get_owners(name)?;
188        Ok(owners.iter().any(|o| o.login == username))
189    }
190
191    /// Check if a version exists in sparse-index metadata.
192    pub fn is_version_visible_in_sparse_index(
193        &self,
194        index_base: &str,
195        name: &str,
196        version: &str,
197    ) -> Result<bool> {
198        let content = self.fetch_sparse_index_file(index_base, name)?;
199        Ok(shipper_sparse_index::contains_version(&content, version))
200    }
201
202    /// Fetch sparse-index content for a crate.
203    pub fn fetch_sparse_index_file(&self, index_base: &str, name: &str) -> Result<String> {
204        let index_base = index_base.trim_end_matches('/');
205        let index_path = sparse_index_path(name);
206        let url = format!("{}/{}", index_base, index_path);
207
208        let cache_file = self.cache_dir.as_ref().map(|d| d.join(&index_path));
209        let etag_file = cache_file.as_ref().map(|f| f.with_extension("etag"));
210
211        let mut request = self.client.get(&url);
212
213        if let Some(ref path) = etag_file
214            && let Ok(etag) = std::fs::read_to_string(path)
215        {
216            request = request.header(reqwest::header::IF_NONE_MATCH, etag.trim());
217        }
218
219        let response = request.send().context("index request failed")?;
220
221        match response.status() {
222            reqwest::StatusCode::OK => {
223                let etag = response
224                    .headers()
225                    .get(reqwest::header::ETAG)
226                    .and_then(|h| h.to_str().ok())
227                    .map(|s| s.to_string());
228                let content = response
229                    .text()
230                    .context("failed to read index response body")?;
231
232                if let Some(ref path) = cache_file {
233                    if let Some(parent) = path.parent() {
234                        let _ = std::fs::create_dir_all(parent);
235                    }
236                    let _ = std::fs::write(path, &content);
237                    if let (Some(ref etag_val), Some(etag_path)) = (etag, etag_file) {
238                        let _ = std::fs::write(etag_path, etag_val);
239                    }
240                }
241                Ok(content)
242            }
243            reqwest::StatusCode::NOT_MODIFIED => {
244                if let Some(ref path) = cache_file {
245                    std::fs::read_to_string(path).context("failed to read cached index file")
246                } else {
247                    Err(anyhow::anyhow!(
248                        "received 304 Not Modified but no cache file available"
249                    ))
250                }
251            }
252            reqwest::StatusCode::NOT_FOUND => Err(anyhow::anyhow!("index file not found: {url}")),
253            status => Err(anyhow::anyhow!(
254                "unexpected status while fetching index: {status}"
255            )),
256        }
257    }
258
259    /// Get the base URL
260    pub fn base_url(&self) -> &str {
261        &self.base_url
262    }
263}
264
265/// Crate information from the registry
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct CrateInfo {
268    /// Crate name
269    pub name: String,
270    /// Newest version available
271    pub newest_version: String,
272    /// When the crate was created
273    pub created_at: String,
274    /// When the crate was last updated
275    pub updated_at: String,
276}
277
278/// Owner information
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct OwnersApiUser {
281    /// Optional owner ID (not guaranteed in all registry responses)
282    pub id: Option<u64>,
283    /// Owner's login/username
284    pub login: String,
285    /// Owner's display name
286    pub name: Option<String>,
287    /// Owner's avatar URL
288    pub avatar: Option<String>,
289}
290
291/// Owner information
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct Owner {
294    /// Owner's login/username
295    pub login: String,
296    /// Owner's display name
297    pub name: Option<String>,
298    /// Owner's avatar URL
299    pub avatar: Option<String>,
300}
301
302/// Response from the crate API
303#[derive(Debug, Deserialize)]
304struct CrateResponse {
305    #[serde(rename = "crate")]
306    crate_data: CrateData,
307}
308
309/// Crate data from API
310#[derive(Debug, Deserialize)]
311struct CrateData {
312    name: String,
313    newest_version: String,
314    created_at: String,
315    updated_at: String,
316}
317
318/// Response from the owners API
319#[derive(Debug, Default, Serialize, Deserialize)]
320pub struct OwnersResponse {
321    pub users: Vec<OwnersApiUser>,
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::{CRATES_IO_API, is_crate_visible, is_version_visible};
328
329    #[test]
330    fn client_creation() {
331        let client = HttpRegistryClient::crates_io();
332        assert_eq!(client.base_url(), "https://crates.io");
333    }
334
335    #[test]
336    fn client_with_custom_url() {
337        let client = HttpRegistryClient::new("https://custom.registry.io/");
338        assert_eq!(client.base_url(), "https://custom.registry.io");
339    }
340
341    #[test]
342    fn client_with_timeout() {
343        let client = HttpRegistryClient::crates_io().with_timeout(Duration::from_secs(60));
344        assert_eq!(client.timeout, Duration::from_secs(60));
345    }
346
347    #[test]
348    fn crate_info_serialization() {
349        let info = CrateInfo {
350            name: "test-crate".to_string(),
351            newest_version: "1.0.0".to_string(),
352            created_at: "2024-01-01T00:00:00Z".to_string(),
353            updated_at: "2024-01-02T00:00:00Z".to_string(),
354        };
355
356        let json = serde_json::to_string(&info).expect("serialize");
357        assert!(json.contains("\"name\":\"test-crate\""));
358    }
359
360    #[test]
361    fn owner_serialization() {
362        let owner = Owner {
363            login: "testuser".to_string(),
364            name: Some("Test User".to_string()),
365            avatar: Some("https://example.com/avatar.png".to_string()),
366        };
367
368        let json = serde_json::to_string(&owner).expect("serialize");
369        assert!(json.contains("\"login\":\"testuser\""));
370    }
371
372    #[derive(Debug, Deserialize)]
373    struct VersionsResponse {
374        versions: Vec<Version>,
375    }
376
377    #[derive(Debug, Deserialize)]
378    struct Version {
379        num: String,
380    }
381
382    #[test]
383    fn versions_response_parsing() {
384        let json = r#"{"versions":[{"num":"1.0.0"},{"num":"0.9.0"}]}"#;
385        let response: VersionsResponse = serde_json::from_str(json).expect("parse");
386        assert_eq!(response.versions.len(), 2);
387        assert_eq!(response.versions[0].num, "1.0.0");
388    }
389
390    #[test]
391    fn crate_response_parsing() {
392        let json = r#"{
393            "crate": {
394                "name": "serde",
395                "newest_version": "1.0.190",
396                "created_at": "2017-01-01T00:00:00Z",
397                "updated_at": "2024-01-01T00:00:00Z"
398            }
399        }"#;
400        let response: CrateResponse = serde_json::from_str(json).expect("parse");
401        assert_eq!(response.crate_data.name, "serde");
402        assert_eq!(response.crate_data.newest_version, "1.0.190");
403    }
404
405    #[test]
406    fn owners_response_parsing() {
407        let json = r#"{
408            "users": [
409                {"login": "user1", "name": "User One", "avatar": null},
410                {"login": "user2", "name": null, "avatar": "https://example.com/avatar.png"}
411            ]
412        }"#;
413        let response: OwnersResponse = serde_json::from_str(json).expect("parse");
414        assert_eq!(response.users.len(), 2);
415        assert_eq!(response.users[0].login, "user1");
416        assert_eq!(
417            response.users[1].avatar,
418            Some("https://example.com/avatar.png".to_string())
419        );
420    }
421
422    #[test]
423    fn user_agent_includes_version() {
424        assert!(USER_AGENT.starts_with("shipper/"));
425        assert!(USER_AGENT.contains(env!("CARGO_PKG_VERSION")));
426    }
427
428    #[test]
429    fn test_sparse_index_caching() {
430        use tiny_http::{Header, Response, Server, StatusCode};
431
432        let server = Server::http("127.0.0.1:0").expect("server");
433        let base_url = format!("http://{}", server.server_addr());
434
435        let td = tempfile::tempdir().expect("tempdir");
436        let cache_dir = td.path().to_path_buf();
437
438        let handle = std::thread::spawn({
439            let _base_url = base_url.clone();
440            move || {
441                // First request: return 200 OK with ETag
442                let req = server.recv().expect("request 1");
443                assert_eq!(req.url(), "/de/mo/demo");
444                let resp = Response::from_string("{\"vers\":\"0.1.0\"}")
445                    .with_status_code(StatusCode(200))
446                    .with_header(Header::from_bytes("ETag", "W/\"123\"").unwrap());
447                req.respond(resp).expect("respond 1");
448
449                // Second request: expect If-None-Match and return 304
450                let req = server.recv().expect("request 2");
451                assert_eq!(req.url(), "/de/mo/demo");
452                let etag_header = req
453                    .headers()
454                    .iter()
455                    .find(|h| h.field.equiv("If-None-Match"))
456                    .expect("missing If-None-Match");
457                assert_eq!(etag_header.value.as_str(), "W/\"123\"");
458
459                let resp = Response::from_string("").with_status_code(StatusCode(304));
460                req.respond(resp).expect("respond 2");
461            }
462        });
463
464        let client = HttpRegistryClient::new(&base_url).with_cache_dir(cache_dir);
465
466        // First call: should fetch and cache
467        let content1 = client
468            .fetch_sparse_index_file(&base_url, "demo")
469            .expect("fetch 1");
470        assert_eq!(content1, "{\"vers\":\"0.1.0\"}");
471
472        // Second call: should use 304 and read from cache
473        let content2 = client
474            .fetch_sparse_index_file(&base_url, "demo")
475            .expect("fetch 2");
476        assert_eq!(content2, "{\"vers\":\"0.1.0\"}");
477
478        handle.join().expect("join");
479    }
480
481    // ── Helper: spin up a tiny_http mock server ──────────────────────
482
483    fn mock_server() -> (tiny_http::Server, String) {
484        let server = tiny_http::Server::http("127.0.0.1:0").expect("mock server");
485        let base = format!("http://{}", server.server_addr());
486        (server, base)
487    }
488
489    fn respond(req: tiny_http::Request, status: u16, body: &str) {
490        let resp =
491            tiny_http::Response::from_string(body).with_status_code(tiny_http::StatusCode(status));
492        req.respond(resp).expect("respond");
493    }
494
495    // ── URL construction ─────────────────────────────────────────────
496
497    #[test]
498    fn url_multiple_trailing_slashes_stripped() {
499        let client = HttpRegistryClient::new("https://example.com///");
500        assert_eq!(client.base_url(), "https://example.com");
501    }
502
503    #[test]
504    fn url_no_trailing_slash_unchanged() {
505        let client = HttpRegistryClient::new("https://example.com");
506        assert_eq!(client.base_url(), "https://example.com");
507    }
508
509    #[test]
510    fn default_timeout_is_30s() {
511        let client = HttpRegistryClient::crates_io();
512        assert_eq!(client.timeout, Duration::from_secs(DEFAULT_TIMEOUT_SECS));
513    }
514
515    #[test]
516    fn with_cache_dir_sets_cache() {
517        let td = tempfile::tempdir().expect("tempdir");
518        let client = HttpRegistryClient::crates_io().with_cache_dir(td.path().to_path_buf());
519        assert_eq!(client.cache_dir, Some(td.path().to_path_buf()));
520    }
521
522    // ── crate_exists (mock) ──────────────────────────────────────────
523
524    #[test]
525    fn crate_exists_returns_true_on_200() {
526        let (server, base) = mock_server();
527        let handle = std::thread::spawn(move || {
528            let req = server.recv().expect("request");
529            assert_eq!(req.url(), "/api/v1/crates/serde");
530            respond(req, 200, r#"{"crate":{}}"#);
531        });
532        let client = HttpRegistryClient::new(&base);
533        assert!(client.crate_exists("serde").expect("ok"));
534        handle.join().expect("join");
535    }
536
537    #[test]
538    fn crate_exists_returns_false_on_404() {
539        let (server, base) = mock_server();
540        let handle = std::thread::spawn(move || {
541            respond(server.recv().expect("req"), 404, "");
542        });
543        let client = HttpRegistryClient::new(&base);
544        assert!(!client.crate_exists("nonexistent").expect("ok"));
545        handle.join().expect("join");
546    }
547
548    #[test]
549    fn crate_exists_returns_error_on_500() {
550        let (server, base) = mock_server();
551        let handle = std::thread::spawn(move || {
552            respond(server.recv().expect("req"), 500, "");
553        });
554        let client = HttpRegistryClient::new(&base);
555        let err = client.crate_exists("bad").unwrap_err();
556        assert!(err.to_string().contains("unexpected status code"));
557        handle.join().expect("join");
558    }
559
560    // ── version_exists (mock) ────────────────────────────────────────
561
562    #[test]
563    fn version_exists_returns_true_on_200() {
564        let (server, base) = mock_server();
565        let handle = std::thread::spawn(move || {
566            let req = server.recv().expect("req");
567            assert_eq!(req.url(), "/api/v1/crates/serde/1.0.0");
568            respond(req, 200, "{}");
569        });
570        let client = HttpRegistryClient::new(&base);
571        assert!(client.version_exists("serde", "1.0.0").expect("ok"));
572        handle.join().expect("join");
573    }
574
575    #[test]
576    fn version_exists_returns_false_on_404() {
577        let (server, base) = mock_server();
578        let handle = std::thread::spawn(move || {
579            respond(server.recv().expect("req"), 404, "");
580        });
581        let client = HttpRegistryClient::new(&base);
582        assert!(!client.version_exists("serde", "99.0.0").expect("ok"));
583        handle.join().expect("join");
584    }
585
586    #[test]
587    fn version_exists_returns_error_on_503() {
588        let (server, base) = mock_server();
589        let handle = std::thread::spawn(move || {
590            respond(server.recv().expect("req"), 503, "");
591        });
592        let client = HttpRegistryClient::new(&base);
593        let err = client.version_exists("x", "0.1.0").unwrap_err();
594        assert!(err.to_string().contains("unexpected status code"));
595        handle.join().expect("join");
596    }
597
598    // ── get_crate_info (mock) ────────────────────────────────────────
599
600    #[test]
601    fn get_crate_info_returns_some_on_200() {
602        let (server, base) = mock_server();
603        let body = r#"{
604            "crate": {
605                "name": "demo",
606                "newest_version": "2.0.0",
607                "created_at": "2023-01-01T00:00:00Z",
608                "updated_at": "2024-06-01T00:00:00Z"
609            }
610        }"#;
611        let handle = std::thread::spawn(move || {
612            respond(server.recv().expect("req"), 200, body);
613        });
614        let client = HttpRegistryClient::new(&base);
615        let info = client.get_crate_info("demo").expect("ok").expect("Some");
616        assert_eq!(info.name, "demo");
617        assert_eq!(info.newest_version, "2.0.0");
618        assert_eq!(info.created_at, "2023-01-01T00:00:00Z");
619        assert_eq!(info.updated_at, "2024-06-01T00:00:00Z");
620        handle.join().expect("join");
621    }
622
623    #[test]
624    fn get_crate_info_returns_none_on_404() {
625        let (server, base) = mock_server();
626        let handle = std::thread::spawn(move || {
627            respond(server.recv().expect("req"), 404, "");
628        });
629        let client = HttpRegistryClient::new(&base);
630        assert!(client.get_crate_info("nope").expect("ok").is_none());
631        handle.join().expect("join");
632    }
633
634    #[test]
635    fn get_crate_info_returns_error_on_500() {
636        let (server, base) = mock_server();
637        let handle = std::thread::spawn(move || {
638            respond(server.recv().expect("req"), 500, "");
639        });
640        let client = HttpRegistryClient::new(&base);
641        let err = client.get_crate_info("bad").unwrap_err();
642        assert!(err.to_string().contains("unexpected status code"));
643        handle.join().expect("join");
644    }
645
646    #[test]
647    fn get_crate_info_returns_error_on_invalid_json() {
648        let (server, base) = mock_server();
649        let handle = std::thread::spawn(move || {
650            respond(server.recv().expect("req"), 200, "NOT JSON");
651        });
652        let client = HttpRegistryClient::new(&base);
653        let err = client.get_crate_info("bad").unwrap_err();
654        assert!(err.to_string().contains("failed to parse crate response"));
655        handle.join().expect("join");
656    }
657
658    // ── owners endpoints (mock) ──────────────────────────────────────
659
660    #[test]
661    fn get_owners_returns_owners_on_200() {
662        let (server, base) = mock_server();
663        let body = r#"{"users":[{"login":"alice","name":"Alice","avatar":null}]}"#;
664        let handle = std::thread::spawn(move || {
665            let req = server.recv().expect("req");
666            assert_eq!(req.url(), "/api/v1/crates/demo/owners");
667            respond(req, 200, body);
668        });
669        let client = HttpRegistryClient::new(&base);
670        let owners = client.get_owners("demo").expect("ok");
671        assert_eq!(owners.len(), 1);
672        assert_eq!(owners[0].login, "alice");
673        handle.join().expect("join");
674    }
675
676    #[test]
677    fn get_owners_returns_empty_on_404() {
678        let (server, base) = mock_server();
679        let handle = std::thread::spawn(move || {
680            respond(server.recv().expect("req"), 404, "");
681        });
682        let client = HttpRegistryClient::new(&base);
683        let owners = client.get_owners("nonexistent").expect("ok");
684        assert!(owners.is_empty());
685        handle.join().expect("join");
686    }
687
688    #[test]
689    fn list_owners_sends_auth_header() {
690        let (server, base) = mock_server();
691        let body = r#"{"users":[{"login":"bob","name":null,"avatar":null}]}"#;
692        let handle = std::thread::spawn(move || {
693            let req = server.recv().expect("req");
694            let auth = req
695                .headers()
696                .iter()
697                .find(|h| h.field.equiv("Authorization"))
698                .expect("missing Authorization");
699            assert_eq!(auth.value.as_str(), "my-token");
700            respond(req, 200, body);
701        });
702        let client = HttpRegistryClient::new(&base);
703        let resp = client.list_owners("demo", "my-token").expect("ok");
704        assert_eq!(resp.users.len(), 1);
705        assert_eq!(resp.users[0].login, "bob");
706        handle.join().expect("join");
707    }
708
709    #[test]
710    fn list_owners_returns_error_on_403() {
711        let (server, base) = mock_server();
712        let handle = std::thread::spawn(move || {
713            respond(server.recv().expect("req"), 403, "");
714        });
715        let client = HttpRegistryClient::new(&base);
716        let err = client.list_owners("demo", "bad-token").unwrap_err();
717        assert!(err.to_string().contains("forbidden"));
718        handle.join().expect("join");
719    }
720
721    #[test]
722    fn list_owners_returns_error_on_401() {
723        let (server, base) = mock_server();
724        let handle = std::thread::spawn(move || {
725            respond(server.recv().expect("req"), 401, "");
726        });
727        let client = HttpRegistryClient::new(&base);
728        let err = client.list_owners("demo", "expired").unwrap_err();
729        assert!(err.to_string().contains("forbidden"));
730        handle.join().expect("join");
731    }
732
733    #[test]
734    fn list_owners_returns_error_on_crate_not_found() {
735        let (server, base) = mock_server();
736        let handle = std::thread::spawn(move || {
737            respond(server.recv().expect("req"), 404, "");
738        });
739        let client = HttpRegistryClient::new(&base);
740        let err = client.list_owners("nope", "token").unwrap_err();
741        assert!(err.to_string().contains("crate not found"));
742        handle.join().expect("join");
743    }
744
745    #[test]
746    fn list_owners_returns_error_on_unexpected_status() {
747        let (server, base) = mock_server();
748        let handle = std::thread::spawn(move || {
749            respond(server.recv().expect("req"), 502, "");
750        });
751        let client = HttpRegistryClient::new(&base);
752        let err = client.list_owners("demo", "tok").unwrap_err();
753        assert!(err.to_string().contains("unexpected status"));
754        handle.join().expect("join");
755    }
756
757    #[test]
758    fn is_owner_returns_true_for_matching_user() {
759        let (server, base) = mock_server();
760        let body = r#"{"users":[{"login":"carol","name":null,"avatar":null}]}"#;
761        let handle = std::thread::spawn(move || {
762            respond(server.recv().expect("req"), 200, body);
763        });
764        let client = HttpRegistryClient::new(&base);
765        assert!(client.is_owner("demo", "carol").expect("ok"));
766        handle.join().expect("join");
767    }
768
769    #[test]
770    fn is_owner_returns_false_for_non_matching_user() {
771        let (server, base) = mock_server();
772        let body = r#"{"users":[{"login":"carol","name":null,"avatar":null}]}"#;
773        let handle = std::thread::spawn(move || {
774            respond(server.recv().expect("req"), 200, body);
775        });
776        let client = HttpRegistryClient::new(&base);
777        assert!(!client.is_owner("demo", "dave").expect("ok"));
778        handle.join().expect("join");
779    }
780
781    // ── sparse index (mock) ──────────────────────────────────────────
782
783    #[test]
784    fn fetch_sparse_index_not_found() {
785        let (server, base) = mock_server();
786        let handle = std::thread::spawn(move || {
787            respond(server.recv().expect("req"), 404, "");
788        });
789        let client = HttpRegistryClient::new(&base);
790        let err = client.fetch_sparse_index_file(&base, "xy").unwrap_err();
791        assert!(err.to_string().contains("index file not found"));
792        handle.join().expect("join");
793    }
794
795    #[test]
796    fn fetch_sparse_index_unexpected_status() {
797        let (server, base) = mock_server();
798        let handle = std::thread::spawn(move || {
799            respond(server.recv().expect("req"), 502, "");
800        });
801        let client = HttpRegistryClient::new(&base);
802        let err = client.fetch_sparse_index_file(&base, "xy").unwrap_err();
803        assert!(err.to_string().contains("unexpected status"));
804        handle.join().expect("join");
805    }
806
807    #[test]
808    fn fetch_sparse_index_304_without_cache_errors() {
809        let (server, base) = mock_server();
810        let handle = std::thread::spawn(move || {
811            respond(server.recv().expect("req"), 304, "");
812        });
813        // No cache_dir set
814        let client = HttpRegistryClient::new(&base);
815        let err = client.fetch_sparse_index_file(&base, "ab").unwrap_err();
816        assert!(err.to_string().contains("304 Not Modified"));
817        handle.join().expect("join");
818    }
819
820    #[test]
821    fn is_version_visible_in_sparse_index_with_mock() {
822        let (server, base) = mock_server();
823        let body = "{\"name\":\"demo\",\"vers\":\"0.1.0\",\"deps\":[]}\n\
824                    {\"name\":\"demo\",\"vers\":\"0.2.0\",\"deps\":[]}";
825        let handle = std::thread::spawn(move || {
826            respond(server.recv().expect("req"), 200, body);
827        });
828        let client = HttpRegistryClient::new(&base);
829        assert!(
830            client
831                .is_version_visible_in_sparse_index(&base, "demo", "0.1.0")
832                .expect("ok")
833        );
834        handle.join().expect("join");
835    }
836
837    #[test]
838    fn is_version_visible_in_sparse_index_returns_false_for_missing_version() {
839        let (server, base) = mock_server();
840        let body = "{\"name\":\"demo\",\"vers\":\"0.1.0\",\"deps\":[]}";
841        let handle = std::thread::spawn(move || {
842            respond(server.recv().expect("req"), 200, body);
843        });
844        let client = HttpRegistryClient::new(&base);
845        assert!(
846            !client
847                .is_version_visible_in_sparse_index(&base, "demo", "9.9.9")
848                .expect("ok")
849        );
850        handle.join().expect("join");
851    }
852
853    // ── convenience functions ────────────────────────────────────────
854
855    #[test]
856    fn is_version_visible_delegates_to_client() {
857        let (server, base) = mock_server();
858        let handle = std::thread::spawn(move || {
859            respond(server.recv().expect("req"), 200, "{}");
860        });
861        assert!(is_version_visible(&base, "serde", "1.0.0").expect("ok"));
862        handle.join().expect("join");
863    }
864
865    #[test]
866    fn is_crate_visible_delegates_to_client() {
867        let (server, base) = mock_server();
868        let handle = std::thread::spawn(move || {
869            respond(server.recv().expect("req"), 200, "{}");
870        });
871        assert!(is_crate_visible(&base, "serde").expect("ok"));
872        handle.join().expect("join");
873    }
874
875    // ── timeout handling ─────────────────────────────────────────────
876
877    #[test]
878    fn timeout_triggers_on_slow_server() {
879        let (server, base) = mock_server();
880        let handle = std::thread::spawn(move || {
881            let req = server.recv().expect("req");
882            // Sleep longer than the client timeout
883            std::thread::sleep(Duration::from_secs(3));
884            let _ = req.respond(tiny_http::Response::from_string("{}"));
885        });
886        let client = HttpRegistryClient::new(&base).with_timeout(Duration::from_millis(200));
887        let result = client.crate_exists("slow");
888        assert!(result.is_err());
889        handle.join().expect("join");
890    }
891
892    // ── connection error ─────────────────────────────────────────────
893
894    #[test]
895    fn crate_exists_handles_connection_refused() {
896        // Use a port that is very unlikely to be listening
897        let client = HttpRegistryClient::new("http://127.0.0.1:1");
898        let result = client.crate_exists("anything");
899        assert!(result.is_err());
900        assert!(
901            result
902                .unwrap_err()
903                .to_string()
904                .contains("failed to send request")
905        );
906    }
907
908    // ── serialization round-trips ────────────────────────────────────
909
910    #[test]
911    fn crate_info_roundtrip() {
912        let info = CrateInfo {
913            name: "foo".to_string(),
914            newest_version: "3.2.1".to_string(),
915            created_at: "2020-01-01T00:00:00Z".to_string(),
916            updated_at: "2025-06-01T00:00:00Z".to_string(),
917        };
918        let json = serde_json::to_string(&info).expect("ser");
919        let back: CrateInfo = serde_json::from_str(&json).expect("de");
920        assert_eq!(back.name, "foo");
921        assert_eq!(back.newest_version, "3.2.1");
922    }
923
924    #[test]
925    fn owner_roundtrip_with_optional_fields() {
926        let owner = Owner {
927            login: "user".to_string(),
928            name: None,
929            avatar: None,
930        };
931        let json = serde_json::to_string(&owner).expect("ser");
932        let back: Owner = serde_json::from_str(&json).expect("de");
933        assert_eq!(back.login, "user");
934        assert!(back.name.is_none());
935        assert!(back.avatar.is_none());
936    }
937
938    #[test]
939    fn owners_api_user_optional_id() {
940        let json = r#"{"login":"alice","name":null,"avatar":null}"#;
941        let user: OwnersApiUser = serde_json::from_str(json).expect("de");
942        assert!(user.id.is_none());
943        assert_eq!(user.login, "alice");
944    }
945
946    #[test]
947    fn owners_api_user_with_id() {
948        let json = r#"{"id":42,"login":"bob","name":"Bob","avatar":"http://a.png"}"#;
949        let user: OwnersApiUser = serde_json::from_str(json).expect("de");
950        assert_eq!(user.id, Some(42));
951        assert_eq!(user.login, "bob");
952    }
953
954    #[test]
955    fn owners_response_default_is_empty() {
956        let resp = OwnersResponse::default();
957        assert!(resp.users.is_empty());
958    }
959
960    // ── sparse_index_path delegation ─────────────────────────────────
961
962    #[test]
963    fn sparse_index_path_short_crate() {
964        assert_eq!(sparse_index_path("a"), "1/a");
965        assert_eq!(sparse_index_path("ab"), "2/ab");
966    }
967
968    #[test]
969    fn sparse_index_path_three_char() {
970        assert_eq!(sparse_index_path("abc"), "3/a/abc");
971    }
972
973    #[test]
974    fn sparse_index_path_four_plus_char() {
975        assert_eq!(sparse_index_path("demo"), "de/mo/demo");
976        assert_eq!(sparse_index_path("serde"), "se/rd/serde");
977    }
978
979    // ── constants ────────────────────────────────────────────────────
980
981    #[test]
982    fn crates_io_api_constant() {
983        assert_eq!(CRATES_IO_API, "https://crates.io");
984    }
985
986    #[test]
987    fn default_timeout_constant() {
988        assert_eq!(DEFAULT_TIMEOUT_SECS, 30);
989    }
990
991    // ── insta snapshot tests ─────────────────────────────────────────
992
993    #[test]
994    fn snapshot_crate_info() {
995        let info = CrateInfo {
996            name: "my-crate".to_string(),
997            newest_version: "1.2.3".to_string(),
998            created_at: "2024-01-15T10:30:00Z".to_string(),
999            updated_at: "2024-06-20T14:00:00Z".to_string(),
1000        };
1001        insta::assert_yaml_snapshot!("crate_info", info);
1002    }
1003
1004    #[test]
1005    fn snapshot_owner_all_fields() {
1006        let owner = Owner {
1007            login: "alice".to_string(),
1008            name: Some("Alice Smith".to_string()),
1009            avatar: Some("https://example.com/alice.png".to_string()),
1010        };
1011        insta::assert_yaml_snapshot!("owner_all_fields", owner);
1012    }
1013
1014    #[test]
1015    fn snapshot_owner_minimal() {
1016        let owner = Owner {
1017            login: "bot-user".to_string(),
1018            name: None,
1019            avatar: None,
1020        };
1021        insta::assert_yaml_snapshot!("owner_minimal", owner);
1022    }
1023
1024    #[test]
1025    fn snapshot_owners_api_user_with_id() {
1026        let user = OwnersApiUser {
1027            id: Some(42),
1028            login: "bob".to_string(),
1029            name: Some("Bob Jones".to_string()),
1030            avatar: Some("https://example.com/bob.png".to_string()),
1031        };
1032        insta::assert_yaml_snapshot!("owners_api_user_with_id", user);
1033    }
1034
1035    #[test]
1036    fn snapshot_owners_api_user_without_id() {
1037        let user = OwnersApiUser {
1038            id: None,
1039            login: "team:core".to_string(),
1040            name: None,
1041            avatar: None,
1042        };
1043        insta::assert_yaml_snapshot!("owners_api_user_without_id", user);
1044    }
1045
1046    #[test]
1047    fn snapshot_owners_response_multiple() {
1048        let resp = OwnersResponse {
1049            users: vec![
1050                OwnersApiUser {
1051                    id: Some(1),
1052                    login: "alice".to_string(),
1053                    name: Some("Alice".to_string()),
1054                    avatar: None,
1055                },
1056                OwnersApiUser {
1057                    id: Some(2),
1058                    login: "bob".to_string(),
1059                    name: None,
1060                    avatar: Some("https://example.com/bob.png".to_string()),
1061                },
1062            ],
1063        };
1064        insta::assert_yaml_snapshot!("owners_response_multiple", resp);
1065    }
1066
1067    #[test]
1068    fn snapshot_owners_response_empty() {
1069        let resp = OwnersResponse::default();
1070        insta::assert_yaml_snapshot!("owners_response_empty", resp);
1071    }
1072
1073    #[test]
1074    fn snapshot_url_construction_crate() {
1075        let client = HttpRegistryClient::new("https://crates.io");
1076        let url = format!("{}/api/v1/crates/{}", client.base_url(), "my-crate");
1077        insta::assert_snapshot!("url_crate", url);
1078    }
1079
1080    #[test]
1081    fn snapshot_url_construction_version() {
1082        let client = HttpRegistryClient::new("https://crates.io");
1083        let url = format!(
1084            "{}/api/v1/crates/{}/{}",
1085            client.base_url(),
1086            "my-crate",
1087            "1.2.3"
1088        );
1089        insta::assert_snapshot!("url_version", url);
1090    }
1091
1092    #[test]
1093    fn snapshot_url_construction_owners() {
1094        let client = HttpRegistryClient::new("https://crates.io");
1095        let url = format!("{}/api/v1/crates/{}/owners", client.base_url(), "my-crate");
1096        insta::assert_snapshot!("url_owners", url);
1097    }
1098
1099    #[test]
1100    fn snapshot_url_construction_custom_registry() {
1101        let client = HttpRegistryClient::new("https://my-registry.example.com/");
1102        let url = format!("{}/api/v1/crates/{}", client.base_url(), "private-lib");
1103        insta::assert_snapshot!("url_custom_registry", url);
1104    }
1105
1106    #[test]
1107    fn snapshot_sparse_index_paths() {
1108        insta::assert_snapshot!("sparse_path_1char", sparse_index_path("a"));
1109        insta::assert_snapshot!("sparse_path_2char", sparse_index_path("ab"));
1110        insta::assert_snapshot!("sparse_path_3char", sparse_index_path("abc"));
1111        insta::assert_snapshot!("sparse_path_4char", sparse_index_path("demo"));
1112        insta::assert_snapshot!("sparse_path_long", sparse_index_path("serde_json"));
1113    }
1114
1115    #[test]
1116    fn snapshot_error_connection_refused() {
1117        let client = HttpRegistryClient::new("http://127.0.0.1:1");
1118        let err = client.crate_exists("anything").unwrap_err();
1119        insta::assert_snapshot!("error_connection_refused", err.to_string());
1120    }
1121
1122    #[test]
1123    fn snapshot_error_unexpected_status_crate_exists() {
1124        let (server, base) = mock_server();
1125        let handle = std::thread::spawn(move || {
1126            respond(server.recv().expect("req"), 500, "");
1127        });
1128        let client = HttpRegistryClient::new(&base);
1129        let err = client.crate_exists("bad").unwrap_err();
1130        insta::assert_snapshot!("error_unexpected_status", err.to_string());
1131        handle.join().expect("join");
1132    }
1133
1134    #[test]
1135    fn snapshot_error_owners_forbidden() {
1136        let (server, base) = mock_server();
1137        let handle = std::thread::spawn(move || {
1138            respond(server.recv().expect("req"), 403, "");
1139        });
1140        let client = HttpRegistryClient::new(&base);
1141        let err = client.list_owners("demo", "bad-token").unwrap_err();
1142        insta::assert_snapshot!("error_owners_forbidden", err.to_string());
1143        handle.join().expect("join");
1144    }
1145
1146    #[test]
1147    fn snapshot_error_owners_not_found() {
1148        let (server, base) = mock_server();
1149        let handle = std::thread::spawn(move || {
1150            respond(server.recv().expect("req"), 404, "");
1151        });
1152        let client = HttpRegistryClient::new(&base);
1153        let err = client.list_owners("nope", "token").unwrap_err();
1154        insta::assert_snapshot!("error_owners_not_found", err.to_string());
1155        handle.join().expect("join");
1156    }
1157
1158    // ── property-based tests ─────────────────────────────────────────
1159
1160    mod proptests {
1161        use super::*;
1162        use proptest::prelude::*;
1163
1164        /// Strategy for valid crate name characters (alphanumeric, hyphen, underscore).
1165        fn crate_name_strategy() -> impl Strategy<Value = String> {
1166            "[a-zA-Z][a-zA-Z0-9_-]{0,63}".prop_filter("non-empty", |s| !s.is_empty())
1167        }
1168
1169        /// Strategy for semver-like version strings.
1170        fn version_strategy() -> impl Strategy<Value = String> {
1171            (
1172                0u32..100,
1173                0u32..100,
1174                0u32..100,
1175                proptest::option::of("[a-z]{1,8}"),
1176            )
1177                .prop_map(|(major, minor, patch, pre)| match pre {
1178                    Some(tag) => format!("{major}.{minor}.{patch}-{tag}"),
1179                    None => format!("{major}.{minor}.{patch}"),
1180                })
1181        }
1182
1183        proptest! {
1184            #[test]
1185            fn url_normalization_strips_trailing_slashes(
1186                base in "[a-z]{3,10}://[a-z]{3,12}\\.[a-z]{2,4}",
1187                slashes in "/{0,10}",
1188            ) {
1189                let input = format!("{base}{slashes}");
1190                let client = HttpRegistryClient::new(&input);
1191                let url = client.base_url();
1192                prop_assert!(!url.ends_with('/'), "URL still has trailing slash: {url}");
1193            }
1194
1195            #[test]
1196            fn sparse_index_path_is_deterministic(name in crate_name_strategy()) {
1197                let a = sparse_index_path(&name);
1198                let b = sparse_index_path(&name);
1199                prop_assert_eq!(&a, &b, "sparse_index_path not deterministic for {}", name);
1200            }
1201
1202            #[test]
1203            fn sparse_index_path_is_lowercase(name in crate_name_strategy()) {
1204                let path = sparse_index_path(&name);
1205                let path_lower = path.to_ascii_lowercase();
1206                prop_assert_eq!(path, path_lower,
1207                    "sparse_index_path should be all lowercase for {}", name);
1208            }
1209
1210            #[test]
1211            fn crate_info_roundtrip_prop(
1212                name in "[a-z_-]{1,30}",
1213                version in version_strategy(),
1214                created in "[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z",
1215                updated in "[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z",
1216            ) {
1217                let info = CrateInfo {
1218                    name: name.clone(),
1219                    newest_version: version.clone(),
1220                    created_at: created.clone(),
1221                    updated_at: updated.clone(),
1222                };
1223                let json = serde_json::to_string(&info).expect("serialize");
1224                let back: CrateInfo = serde_json::from_str(&json).expect("deserialize");
1225                prop_assert_eq!(&back.name, &name);
1226                prop_assert_eq!(&back.newest_version, &version);
1227                prop_assert_eq!(&back.created_at, &created);
1228                prop_assert_eq!(&back.updated_at, &updated);
1229            }
1230
1231            #[test]
1232            fn version_string_in_url_construction(
1233                version in version_strategy(),
1234            ) {
1235                let client = HttpRegistryClient::new("https://example.com");
1236                let expected = format!("https://example.com/api/v1/crates/test-crate/{version}");
1237                let url = format!("{}/api/v1/crates/{}/{}", client.base_url(), "test-crate", version);
1238                prop_assert_eq!(url, expected);
1239            }
1240
1241            #[test]
1242            fn owners_response_roundtrip_prop(
1243                logins in prop::collection::vec("[a-z]{1,20}", 0..5),
1244            ) {
1245                let resp = OwnersResponse {
1246                    users: logins.iter().map(|login| OwnersApiUser {
1247                        id: None,
1248                        login: login.clone(),
1249                        name: None,
1250                        avatar: None,
1251                    }).collect(),
1252                };
1253                let json = serde_json::to_string(&resp).expect("serialize");
1254                let back: OwnersResponse = serde_json::from_str(&json).expect("deserialize");
1255                prop_assert_eq!(back.users.len(), resp.users.len());
1256                for (a, b) in resp.users.iter().zip(back.users.iter()) {
1257                    prop_assert_eq!(&a.login, &b.login);
1258                }
1259            }
1260        }
1261    }
1262}