Skip to main content

cleanlib_client/
transport.rs

1//! HTTP transport — `Client` wraps reqwest with auth header injection +
2//! timeout + per Client spec rev1 §2 + Rev 2 amendment §2 + §4.
3//!
4//! Phase 1 substrate: `fetch_verdict` is the first transport consumer
5//! (CLEANLIB-30); subsequent CLEANLIB-N PRs add `scan` / `audit` /
6//! `policy preview` / `fetch` verb impls reusing this `Client`.
7
8use std::time::Duration;
9
10use reqwest::{header, Client as ReqwestClient, Method, Url};
11
12use crate::config::Config;
13use crate::errors::{from_http, CleanLibraryError, TransportError};
14use crate::types::{
15    AuditResponse, PolicyPreviewRequest, PolicyPreviewResponse, ScanRequest, ScanResponse, Verdict,
16};
17
18/// Default HTTP timeout per Rev 2 amendment §4.1 — allows 1-5s GCS-catalog
19/// first-request-ingest + buffer.
20const DEFAULT_TIMEOUT_SECS: u64 = 30;
21
22/// `Client` is the cleanlib-client HTTP transport handle. Cheap to clone;
23/// share across verbs in the same process.
24#[derive(Debug, Clone)]
25pub struct Client {
26    http: ReqwestClient,
27    base_url: Url,
28    api_key: Option<String>,
29    api_version: String,
30}
31
32impl Client {
33    /// Construct from a loaded `Config`. TLS required for non-localhost
34    /// endpoints. Returns `TlsRequired` for `http://` on remote hosts.
35    pub fn from_config(config: &Config) -> Result<Self, CleanLibraryError> {
36        Self::build(&config.endpoint.url, config.auth.api_key.clone(), &config.endpoint.api_version)
37    }
38
39    /// Construct with explicit endpoint + api_key. Intended for integration
40    /// tests + ad-hoc invocations (e.g., CLI `--endpoint=` flag future).
41    pub fn new(endpoint: &str, api_key: Option<String>) -> Result<Self, CleanLibraryError> {
42        Self::build(endpoint, api_key, "v1")
43    }
44
45    fn build(
46        endpoint: &str,
47        api_key: Option<String>,
48        api_version: &str,
49    ) -> Result<Self, CleanLibraryError> {
50        let base_url = Url::parse(endpoint)
51            .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", endpoint, e)))?;
52
53        let is_localhost = matches!(
54            base_url.host_str(),
55            Some("localhost") | Some("127.0.0.1") | Some("::1")
56        );
57        if base_url.scheme() != "https" && !is_localhost {
58            return Err(TransportError::TlsRequired(endpoint.to_string()).into());
59        }
60
61        let http = ReqwestClient::builder()
62            .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
63            .user_agent(concat!("cleanlib-cli/", env!("CARGO_PKG_VERSION")))
64            .build()
65            .map_err(TransportError::Network)?;
66
67        Ok(Self {
68            http,
69            base_url,
70            api_key,
71            api_version: api_version.to_string(),
72        })
73    }
74
75    /// Fetch a single verdict per App Rev 4 §9.3 +
76    /// `GET /v1/customer/verdicts/{ecosystem}/{package}/{version}`.
77    pub async fn fetch_verdict(
78        &self,
79        ecosystem: &str,
80        package: &str,
81        version: &str,
82    ) -> Result<Verdict, CleanLibraryError> {
83        let path = format!(
84            "{}/customer/verdicts/{}/{}/{}",
85            self.api_version,
86            urlencode(ecosystem),
87            urlencode(package),
88            urlencode(version),
89        );
90        let url = self
91            .base_url
92            .join(&path)
93            .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
94
95        let response = self.send(Method::GET, url).await?;
96        let status = response.status();
97        let headers = response.headers().clone();
98        let body = response.text().await.map_err(TransportError::Network)?;
99
100        if !status.is_success() {
101            return Err(from_http(status.as_u16(), &headers, &body));
102        }
103
104        serde_json::from_str(&body)
105            .map_err(|e| CleanLibraryError::Parse(format!("verdict response: {}", e)))
106    }
107
108    /// Submit a packages + optional-policy request to
109    /// `POST /v1/scan` — batch-resolve verdicts for a set of packages against
110    /// the customer's active policy. Used by `cleanlib scan`. The App resolves
111    /// each package independently (partial-success: a per-package miss lands as
112    /// `ScanResult.error`, not a whole-batch failure), so the caller derives
113    /// the gating decision per `ScanResult.verdict` and aggregates the exit
114    /// code (`commands::scan`).
115    ///
116    /// Distinct endpoint from [`Self::policy_preview`]: `/v1/scan` needs no
117    /// `policy_yaml`. Routing scan through `/v1/policy/preview` produced a
118    /// `422 missing field policy_yaml`, and the old `PolicyPreviewResponse`
119    /// (`{decisions}`, `#[serde(default)]`) silently parsed the App's
120    /// `{results}` body into an empty vec → `scan_exit_code(&[]) == 0`, a
121    /// fail-open on the security gate. Both are closed here.
122    pub async fn scan(&self, req: &ScanRequest) -> Result<ScanResponse, CleanLibraryError> {
123        let path = format!("{}/scan", self.api_version);
124        let url = self
125            .base_url
126            .join(&path)
127            .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
128
129        let body = serde_json::to_vec(req)
130            .map_err(|e| CleanLibraryError::Parse(format!("scan body: {}", e)))?;
131
132        let response = self
133            .send_with_body(Method::POST, url, body, "application/json")
134            .await?;
135        let status = response.status();
136        let headers = response.headers().clone();
137        let body = response.text().await.map_err(TransportError::Network)?;
138
139        if !status.is_success() {
140            return Err(from_http(status.as_u16(), &headers, &body));
141        }
142
143        serde_json::from_str(&body)
144            .map_err(|e| CleanLibraryError::Parse(format!("scan response: {}", e)))
145    }
146
147    /// `POST /v1/policy/preview`. Used by `cleanlib policy preview` (with an
148    /// explicit candidate policy override). NOTE: `cleanlib scan` uses
149    /// [`Self::scan`] (`/v1/scan`), NOT this endpoint.
150    ///
151    /// CLEANLIB-305 DX-fix: corrected from `/v1/customer/policy/preview`
152    /// (which 404s) to `/v1/policy/preview` (which the App mounts via
153    /// `verbs_router`). Same class of bug as the cycle-14 `/v1/audit`
154    /// fix (see `audit` below): the `/customer/` prefix is used only by
155    /// `customer_verdicts_router` (`/v1/customer/verdicts/*`); the
156    /// cycle-6 verb surface (`scan`, `audit`, `policy/preview`,
157    /// `risk-accept`, `fetch/*`) mounts flat under `/v1`.
158    pub async fn policy_preview(
159        &self,
160        req: &PolicyPreviewRequest,
161    ) -> Result<PolicyPreviewResponse, CleanLibraryError> {
162        let path = format!("{}/policy/preview", self.api_version);
163        let url = self
164            .base_url
165            .join(&path)
166            .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
167
168        let body = serde_json::to_vec(req)
169            .map_err(|e| CleanLibraryError::Parse(format!("policy_preview body: {}", e)))?;
170
171        let response = self.send_with_body(Method::POST, url, body, "application/json").await?;
172        let status = response.status();
173        let headers = response.headers().clone();
174        let body = response.text().await.map_err(TransportError::Network)?;
175
176        if !status.is_success() {
177            return Err(from_http(status.as_u16(), &headers, &body));
178        }
179
180        serde_json::from_str(&body)
181            .map_err(|e| CleanLibraryError::Parse(format!("policy_preview response: {}", e)))
182    }
183
184    /// Fetch the raw artifact bytes for `(ecosystem, package, version)` via
185    /// App's unified catalog-proxy `GET /v1/fetch/{ecosystem}/{package}/{version}`
186    /// (CLEANLIB-302 / CLEANLIB-368). Returns owned `Vec<u8>` — caller decides
187    /// write target. Emits decision + reason headers to stderr for visibility
188    /// (binary stdout stays clean).
189    ///
190    /// **CLEANLIB-368 route pivot**: prior cycles built per-ecosystem registry
191    /// paths (`/npm/<pkg>/-/<pkg>-<ver>.tgz`, `/go/<pkg>/@v/<ver>.zip`, …)
192    /// against the App's cycle-3 §C.8 nested per-eco routers. Those paths are
193    /// legacy registry-mimic shapes that never surfaced the CLEANLIB-302 audit
194    /// row (`gcs_hit` / `gcs_object_path` / `bytes_served`) and hard-coded
195    /// pypi wheel-variant assumptions that diverge from the real serve path.
196    /// The App unifies both under `verbs_router` at `/v1/fetch/*` — that is
197    /// now the sole client-side route.
198    pub async fn fetch_artifact(
199        &self,
200        ecosystem: &str,
201        package: &str,
202        version: &str,
203    ) -> Result<Vec<u8>, CleanLibraryError> {
204        let url = build_fetch_url(&self.base_url, &self.api_version, ecosystem, package, version)?;
205        let response = self.send(Method::GET, url).await?;
206        let status = response.status();
207        let headers = response.headers().clone();
208
209        if !status.is_success() {
210            let body = response.text().await.map_err(TransportError::Network)?;
211            return Err(from_http(status.as_u16(), &headers, &body));
212        }
213
214        emit_decision_headers(&headers);
215
216        let bytes = response.bytes().await.map_err(TransportError::Network)?;
217        Ok(bytes.to_vec())
218    }
219
220    /// Streaming variant of [`Self::fetch_artifact`] — writes chunks to
221    /// `writer` without buffering the full body in memory. Per Client Rev 2
222    /// amendment §9.4 cycle-4 §D.5 streaming substrate. Returns total bytes
223    /// written. Decision + reason headers surface to stderr before stream.
224    /// Hits the same unified `/v1/fetch/{ecosystem}/{package}/{version}`
225    /// proxy as [`Self::fetch_artifact`] — see the CLEANLIB-368 route pivot
226    /// note there.
227    pub async fn fetch_artifact_stream<W>(
228        &self,
229        ecosystem: &str,
230        package: &str,
231        version: &str,
232        writer: &mut W,
233    ) -> Result<u64, CleanLibraryError>
234    where
235        W: tokio::io::AsyncWrite + Unpin,
236    {
237        use futures_util::StreamExt;
238        use tokio::io::AsyncWriteExt;
239
240        let url = build_fetch_url(&self.base_url, &self.api_version, ecosystem, package, version)?;
241        let response = self.send(Method::GET, url).await?;
242        let status = response.status();
243        let headers = response.headers().clone();
244
245        if !status.is_success() {
246            let body = response.text().await.map_err(TransportError::Network)?;
247            return Err(from_http(status.as_u16(), &headers, &body));
248        }
249
250        emit_decision_headers(&headers);
251
252        let mut total: u64 = 0;
253        let mut stream = response.bytes_stream();
254        while let Some(chunk) = stream.next().await {
255            let bytes = chunk.map_err(TransportError::Network)?;
256            writer
257                .write_all(&bytes)
258                .await
259                .map_err(|e| CleanLibraryError::Parse(format!("write artifact chunk: {}", e)))?;
260            total += bytes.len() as u64;
261        }
262        writer
263            .flush()
264            .await
265            .map_err(|e| CleanLibraryError::Parse(format!("flush artifact stream: {}", e)))?;
266        Ok(total)
267    }
268
269    /// Query customer audit log via `GET /v1/audit` with optional filters.
270    /// Caller passes already-validated filter values.
271    ///
272    /// Cycle-14 DX-fix: corrected from `/v1/customer/audit` (which 404s) to
273    /// `/v1/audit` (which the App mounts via `verbs_router`). Verified live
274    /// against cleanapp.clnstrt.dev 2026-06-05 — direct probe returns 200
275    /// with `{window, records, backend_status}`.
276    pub async fn audit(
277        &self,
278        since: Option<&str>,
279        decision: Option<&str>,
280        ecosystem: Option<&str>,
281    ) -> Result<AuditResponse, CleanLibraryError> {
282        let path = format!("{}/audit", self.api_version);
283        let mut url = self
284            .base_url
285            .join(&path)
286            .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
287        {
288            let mut q = url.query_pairs_mut();
289            if let Some(s) = since {
290                q.append_pair("since", s);
291            }
292            if let Some(d) = decision {
293                q.append_pair("decision", d);
294            }
295            if let Some(e) = ecosystem {
296                q.append_pair("ecosystem", e);
297            }
298        }
299
300        let response = self.send(Method::GET, url).await?;
301        let status = response.status();
302        let headers = response.headers().clone();
303        let body = response.text().await.map_err(TransportError::Network)?;
304
305        if !status.is_success() {
306            return Err(from_http(status.as_u16(), &headers, &body));
307        }
308
309        serde_json::from_str(&body)
310            .map_err(|e| CleanLibraryError::Parse(format!("audit response: {}", e)))
311    }
312
313        pub async fn probe_auth(&self) -> Result<(), CleanLibraryError> {
314        let path = format!("{}/audit", self.api_version);
315        let url = self
316            .base_url
317            .join(&path)
318            .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
319        let response = self.send(Method::GET, url).await?;
320        let status = response.status();
321        let headers = response.headers().clone();
322        let body = response.text().await.map_err(TransportError::Network)?;
323        if status.is_success() {
324            return Ok(());
325        }
326        Err(from_http(status.as_u16(), &headers, &body))
327    }
328
329    /// Low-level: send a request with body + content-type + auth header.
330    async fn send_with_body(
331        &self,
332        method: Method,
333        url: Url,
334        body: Vec<u8>,
335        content_type: &str,
336    ) -> Result<reqwest::Response, CleanLibraryError> {
337        let mut req = self.http.request(method, url).body(body);
338        req = req.header(header::CONTENT_TYPE, content_type);
339        if let Some(key) = &self.api_key {
340            req = req.header(header::AUTHORIZATION, format!("Bearer {}", key));
341        }
342        req.send().await.map_err(|e| {
343            if e.is_timeout() {
344                CleanLibraryError::Transport(TransportError::Timeout)
345            } else {
346                CleanLibraryError::Transport(TransportError::Network(e))
347            }
348        })
349    }
350
351    /// Low-level: send a request with auth header. Used internally by verb
352    /// methods; public for advanced consumers (future).
353    pub async fn send(
354        &self,
355        method: Method,
356        url: Url,
357    ) -> Result<reqwest::Response, CleanLibraryError> {
358        let mut req = self.http.request(method, url);
359        if let Some(key) = &self.api_key {
360            req = req.header(header::AUTHORIZATION, format!("Bearer {}", key));
361        }
362        req.send().await.map_err(|e| {
363            if e.is_timeout() {
364                CleanLibraryError::Transport(TransportError::Timeout)
365            } else {
366                CleanLibraryError::Transport(TransportError::Network(e))
367            }
368        })
369    }
370
371    /// Expose the base URL for diagnostics + integration tests.
372    pub fn base_url(&self) -> &Url {
373        &self.base_url
374    }
375
376    /// Fetch supported ecosystems from GET /health ecosystems_mounted field.
377    pub async fn get_ecosystems(&self) -> Result<Vec<String>, CleanLibraryError> {
378        let url = self.base_url.join("/health")
379            .map_err(|e| CleanLibraryError::Transport(
380                TransportError::InvalidUrl(e.to_string())
381            ))?;
382        let resp = self.http.get(url).send().await.map_err(|e| {
383            CleanLibraryError::Transport(TransportError::Network(e))
384        })?;
385        let body_text = resp.text().await.map_err(|e| {
386            CleanLibraryError::Transport(TransportError::Network(e))
387        })?;
388        let body: serde_json::Value = serde_json::from_str(&body_text)
389            .map_err(|e| CleanLibraryError::Parse(e.to_string()))?;
390        let ecosystems = body["ecosystems_mounted"]
391            .as_array()
392            .unwrap_or(&vec![])
393            .iter()
394            .filter_map(|v| v.as_str().map(|s| s.to_string()))
395            .collect();
396        Ok(ecosystems)
397    }
398}
399
400/// Emit App's verdict + reason headers (when present) to stderr. Keeps
401/// stdout clean for binary content + scripted consumers; visible to TTY
402/// users for verdict awareness.
403fn emit_decision_headers(headers: &reqwest::header::HeaderMap) {
404    if let Some(decision) = headers
405        .get("X-CleanLibrary-Decision")
406        .and_then(|v| v.to_str().ok())
407    {
408        eprintln!("# decision: {}", decision);
409    }
410    if let Some(reason) = headers
411        .get("X-CleanLibrary-Reason")
412        .and_then(|v| v.to_str().ok())
413    {
414        eprintln!("# reason: {}", reason);
415    }
416}
417
418/// Build the App-side catalog-proxy URL for a triple. Post-CLEANLIB-368 this
419/// is the ONLY fetch URL shape the client emits: `GET /{api_version}/fetch/
420/// {ecosystem}/{package}/{version}` — the unified verb the App mounts in
421/// `verbs_router` (see `cleanlib-app/src/verbs.rs`), which returns
422/// `application/octet-stream` on catalog-hit and a structured JSON 404 on
423/// miss. Per CLEANLIB-302 this is the surface that emits the fetch-audit
424/// `AuditRow` with `gcs_hit` / `gcs_object_path` / `bytes_served`
425/// populated from the real catalog outcome — the substrate the
426/// `/v1/audit` cache-hit-ratio metric consumes.
427///
428/// Every triple component is percent-encoded so path-embedded slashes
429/// (npm-scoped `@scope/name`, go-module `github.com/…/…`) survive the
430/// App's axum `Path<(String, String, String)>` extractor as a single
431/// segment each.
432fn build_fetch_url(
433    base: &Url,
434    api_version: &str,
435    ecosystem: &str,
436    package: &str,
437    version: &str,
438) -> Result<Url, TransportError> {
439    let path = format!(
440        "{}/fetch/{}/{}/{}",
441        api_version,
442        urlencode(ecosystem),
443        urlencode(package),
444        urlencode(version),
445    );
446    base.join(&path)
447        .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))
448}
449
450/// URL-encode a path segment. Covers `@` + `/` + reserved chars + unicode.
451/// Avoids pulling the full `url` crate as a separate dep (re-export is via
452/// `reqwest::Url`).
453fn urlencode(s: &str) -> String {
454    let mut out = String::with_capacity(s.len());
455    for c in s.chars() {
456        match c {
457            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(c),
458            _ => {
459                let mut buf = [0u8; 4];
460                let encoded = c.encode_utf8(&mut buf);
461                for b in encoded.bytes() {
462                    out.push_str(&format!("%{:02X}", b));
463                }
464            }
465        }
466    }
467    out
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::config::{Config, EndpointConfig};
474
475    fn cfg(url: &str) -> Config {
476        let mut c = Config::default();
477        c.endpoint = EndpointConfig {
478            url: url.to_string(),
479            api_version: "v1".to_string(),
480        };
481        c
482    }
483
484    #[test]
485    fn refuses_remote_plaintext() {
486        let err = Client::from_config(&cfg("http://cleanapp.clnstrt.dev")).unwrap_err();
487        assert!(matches!(
488            err,
489            CleanLibraryError::Transport(TransportError::TlsRequired(_))
490        ));
491    }
492
493    #[test]
494    fn allows_localhost_plaintext_for_testing() {
495        let client = Client::new("http://localhost:8080", None).unwrap();
496        assert_eq!(client.base_url().host_str(), Some("localhost"));
497    }
498
499    #[test]
500    fn allows_127_loopback_plaintext() {
501        let client = Client::new("http://127.0.0.1:8080", None).unwrap();
502        assert_eq!(client.base_url().host_str(), Some("127.0.0.1"));
503    }
504
505    #[test]
506    fn accepts_https_endpoint() {
507        let client = Client::from_config(&cfg("https://cleanapp.clnstrt.dev")).unwrap();
508        assert_eq!(client.base_url().as_str(), "https://cleanapp.clnstrt.dev/");
509    }
510
511    #[test]
512    fn rejects_invalid_url() {
513        let err = Client::from_config(&cfg("not a url")).unwrap_err();
514        assert!(matches!(
515            err,
516            CleanLibraryError::Transport(TransportError::InvalidUrl(_))
517        ));
518    }
519
520    #[test]
521    fn urlencode_npm_scoped_pkg() {
522        // @scope/pkg → %40scope%2Fpkg for safe URL embedding
523        assert_eq!(urlencode("@my-org/foo"), "%40my-org%2Ffoo");
524    }
525
526    #[test]
527    fn urlencode_passes_simple() {
528        assert_eq!(urlencode("lodash"), "lodash");
529        assert_eq!(urlencode("4.17.21"), "4.17.21");
530        assert_eq!(urlencode("github.com/sirupsen/logrus"), "github.com%2Fsirupsen%2Flogrus");
531    }
532
533    #[test]
534    fn urlencode_handles_unicode() {
535        // UTF-8 multi-byte chars get percent-encoded byte-by-byte
536        assert_eq!(urlencode("é"), "%C3%A9");
537    }
538
539    fn base() -> Url {
540        Url::parse("https://cleanapp.clnstrt.dev").unwrap()
541    }
542
543    // CLEANLIB-368 route pivot: every ecosystem now resolves to the App's
544    // unified `/{api_version}/fetch/{ecosystem}/{package}/{version}` catalog
545    // proxy. The client no longer emits registry-mimic per-ecosystem paths
546    // (`/npm/…/-/….tgz`, `/go/…/@v/….zip`, `/pypi/…/…-….tar.gz`) — those
547    // URL-shape assertions are retired because they are the pre-pivot bug.
548    #[test]
549    fn fetch_url_routes_through_app_v1_fetch_for_npm_bare() {
550        let url = build_fetch_url(&base(), "v1", "npm", "lodash", "4.17.21").unwrap();
551        assert_eq!(
552            url.as_str(),
553            "https://cleanapp.clnstrt.dev/v1/fetch/npm/lodash/4.17.21"
554        );
555    }
556
557    #[test]
558    fn fetch_url_npm_scoped_encodes_at_and_slash() {
559        // `@my-org/foo` must survive the App's Path extractor as a single
560        // `{package}` segment — so `@` → `%40` and `/` → `%2F`.
561        let url = build_fetch_url(&base(), "v1", "npm", "@my-org/foo", "1.0.0").unwrap();
562        assert_eq!(
563            url.as_str(),
564            "https://cleanapp.clnstrt.dev/v1/fetch/npm/%40my-org%2Ffoo/1.0.0"
565        );
566    }
567
568    #[test]
569    fn fetch_url_go_module_path_slashes_encoded() {
570        // `github.com/sirupsen/logrus` is one package identifier; its
571        // internal `/`s must not split into extra path segments.
572        let url = build_fetch_url(&base(), "v1", "go", "github.com/sirupsen/logrus", "v1.9.0")
573            .unwrap();
574        assert_eq!(
575            url.as_str(),
576            "https://cleanapp.clnstrt.dev/v1/fetch/go/github.com%2Fsirupsen%2Flogrus/v1.9.0"
577        );
578    }
579
580    #[test]
581    fn fetch_url_pypi_routes_through_v1_fetch_not_registry_mimic() {
582        let url = build_fetch_url(&base(), "v1", "pypi", "requests", "2.31.0").unwrap();
583        assert_eq!(
584            url.as_str(),
585            "https://cleanapp.clnstrt.dev/v1/fetch/pypi/requests/2.31.0"
586        );
587        // Explicitly assert the legacy pypi shape is NOT emitted.
588        assert!(!url.as_str().contains("/pypi/requests/requests-"));
589    }
590
591    #[test]
592    fn fetch_url_ecosystem_client_side_unopinionated() {
593        // Post-pivot the client no longer rejects any ecosystem name; the
594        // App owns catalog-lookup + returns a structured 404 on miss. This
595        // closes the CLEANLIB-368 gap that had maven/crates/nuget/rubygems
596        // failing at the client with `Phase 1 Tier A` before even hitting
597        // the wire.
598        for eco in ["maven", "crates", "nuget", "rubygems", "composer"] {
599            let url = build_fetch_url(&base(), "v1", eco, "somepkg", "1.0.0").unwrap();
600            assert_eq!(
601                url.as_str(),
602                format!("https://cleanapp.clnstrt.dev/v1/fetch/{}/somepkg/1.0.0", eco)
603            );
604        }
605    }
606
607    #[test]
608    fn fetch_url_maven_coordinates_group_id_encoded() {
609        // Maven coordinates use `group:artifact` (no `/`), but the `:` is a
610        // reserved char in URL segments — urlencode covers it.
611        let url = build_fetch_url(&base(), "v1", "maven", "junit:junit", "4.13.2").unwrap();
612        assert_eq!(
613            url.as_str(),
614            "https://cleanapp.clnstrt.dev/v1/fetch/maven/junit%3Ajunit/4.13.2"
615        );
616    }
617
618    #[test]
619    fn fetch_url_honors_configured_api_version() {
620        // Future-proof: if the config carries `api_version = "v2"`, the
621        // client picks it up — no hard-coded `v1` in transport.
622        let url = build_fetch_url(&base(), "v2", "npm", "lodash", "4.17.21").unwrap();
623        assert_eq!(
624            url.as_str(),
625            "https://cleanapp.clnstrt.dev/v2/fetch/npm/lodash/4.17.21"
626        );
627    }
628}