Skip to main content

cleanlib_client/
proxy.rs

1//! Per-ecosystem proxy-config emit per [per-ecosystem-proxy-config-emit-format
2//! decision 2026-05-20]. Phase 1 Tier A: npm + pypi + go.
3//!
4//! CLEANLIB-373 + CLEANLIB-374 (cycle-18): extend the accepted-ecosystem list
5//! with `crates` (cargo) and `maven`. The 8-ecosystem catalog the wire supports
6//! today (npm, pypi, go, crates, maven, nuget, rubygems, composer) was already
7//! locked in `cli_matrix` fixtures + the ecosystem-specific crates
8//! (`cleanlib-ecosystem-*`); `config init` was still refusing two of the eight
9//! at the CLI validation layer despite the App backend accepting them.
10//!
11//! Each emit function returns a [`ProxyConfig`] holding the config-blob text +
12//! the canonical local path where the file would be written. Callers
13//! (typically [`cleanlib config init`]) decide whether to write or print.
14
15use std::path::PathBuf;
16
17use thiserror::Error;
18
19/// Locked vocabulary per matrix §8 — ecosystem identifiers are always lowercase.
20///
21/// CLEANLIB-373 + CLEANLIB-374 close: `Crates` and `Maven` join the accepted
22/// set. Both emit shell-snippet form (no `canonical_location`) because the
23/// canonical config file placement is workflow-dependent — cargo per-user
24/// (`~/.cargo/config.toml`) vs per-workspace, maven per-user
25/// (`~/.m2/settings.xml`) vs per-project `mvn -s`. `config init` prints the
26/// snippet + the recommended file path in the header line rather than
27/// silently mutating either default. Sister of the `Ecosystem::Go` shape
28/// which follows the same shell-snippet pattern for GOPROXY / GOAUTH.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Ecosystem {
31    Npm,
32    Pypi,
33    Go,
34    /// CLEANLIB-373 — cargo / crates.io registry (rustaceans).
35    Crates,
36    /// CLEANLIB-374 — Maven Central (JVM: maven/gradle/sbt).
37    Maven,
38}
39
40impl Ecosystem {
41    pub fn parse(s: &str) -> Option<Self> {
42        match s {
43            "npm" => Some(Self::Npm),
44            "pypi" => Some(Self::Pypi),
45            "go" => Some(Self::Go),
46            // CLEANLIB-373 + CLEANLIB-374 — lowercase vocabulary only, matches
47            // the ecosystem identifier the App wire uses (never `cargo` /
48            // `mvn` / `MAVEN` — matrix §8 locks lowercase canonical names).
49            "crates" => Some(Self::Crates),
50            "maven" => Some(Self::Maven),
51            _ => None,
52        }
53    }
54    pub fn as_str(self) -> &'static str {
55        match self {
56            Self::Npm => "npm",
57            Self::Pypi => "pypi",
58            Self::Go => "go",
59            Self::Crates => "crates",
60            Self::Maven => "maven",
61        }
62    }
63}
64
65impl Ecosystem {
66    pub const ALL: &'static [Ecosystem] = &[
67        Ecosystem::Npm,
68        Ecosystem::Pypi,
69        Ecosystem::Go,
70        Ecosystem::Crates,
71        Ecosystem::Maven,
72    ];
73
74    pub fn supported_list() -> String {
75        Self::ALL
76            .iter()
77            .map(|e| e.as_str())
78            .collect::<Vec<_>>()
79            .join(", ")
80    }
81}
82
83/// Emitted config: blob is the literal file/shell content; canonical_location
84/// is the default write path (or empty for ecosystems without a single
85/// canonical file like Go's `GOPROXY` env).
86#[derive(Debug)]
87pub struct ProxyConfig {
88    pub ecosystem: Ecosystem,
89    pub config_blob: String,
90    pub canonical_location: PathBuf,
91}
92
93#[derive(Debug, Error)]
94pub enum ProxyConfigError {
95    #[error("home directory not discoverable; cannot resolve canonical location for {0:?}")]
96    HomeDirUnavailable(Ecosystem),
97    /// CLEANLIB-129 / Jira CLEANLIB-28: refuse `inline_token=true` when the
98    /// provided `api_key` is missing or empty/whitespace-only. Pre-fix
99    /// behaviour emitted `_authToken=` (empty) → broken `.npmrc`.
100    #[error(
101        "inline_token requires a non-empty api_key (would emit broken auth header for {0:?})"
102    )]
103    InlineTokenEmpty(Ecosystem),
104}
105
106/// Options controlling emit shape.
107pub struct EmitOptions {
108    /// Base App endpoint, e.g., `https://cleanapp.clnstrt.dev`. Trailing slash
109    /// is tolerated.
110    pub endpoint: String,
111    /// Optional npm scope (e.g., `@my-org`). Only applies to npm emit.
112    pub scope: Option<String>,
113    /// If `true`, embed `api_key` literal in the emitted config (use for CI
114    /// runners without env-var support). If `false`, emit
115    /// `${CLEANLIBRARY_API_KEY}` shell-expansion (default; preferred).
116    pub inline_token: bool,
117    /// API-key value to embed when `inline_token = true`. Ignored otherwise.
118    pub api_key: Option<String>,
119}
120
121pub fn emit(ecosystem: Ecosystem, opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
122    // CLEANLIB-129 / Jira CLEANLIB-28 defense-in-depth: even when the CLI
123    // forgot to validate, never let the proxy emit `_authToken=` (empty)
124    // out the back. Sister of the `cleanlib config init --inline-token`
125    // pre-check in `cleanlib-cli/src/commands/config_init.rs`.
126    if opts.inline_token {
127        let has_usable_key = opts
128            .api_key
129            .as_deref()
130            .map(|k| !k.trim().is_empty())
131            .unwrap_or(false);
132        if !has_usable_key {
133            return Err(ProxyConfigError::InlineTokenEmpty(ecosystem));
134        }
135    }
136    match ecosystem {
137        Ecosystem::Npm => emit_npm(opts),
138        Ecosystem::Pypi => emit_pypi(opts),
139        Ecosystem::Go => emit_go(opts),
140        Ecosystem::Crates => emit_crates(opts),
141        Ecosystem::Maven => emit_maven(opts),
142    }
143}
144
145fn token_expression(opts: &EmitOptions) -> String {
146    if opts.inline_token {
147        // `emit` guarantees `api_key` is Some(non_empty) when
148        // `inline_token=true`; this default is dead code on the success
149        // path and only reachable via direct internal calls.
150        opts.api_key.clone().unwrap_or_default()
151    } else {
152        "${CLEANLIBRARY_API_KEY}".to_string()
153    }
154}
155
156fn endpoint_host(endpoint: &str) -> &str {
157    endpoint
158        .trim_end_matches('/')
159        .trim_start_matches("https://")
160        .trim_start_matches("http://")
161}
162
163fn emit_npm(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
164    let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Npm))?;
165    let endpoint = opts.endpoint.trim_end_matches('/');
166    let registry_url = format!("{}/npm/", endpoint);
167    let host = endpoint_host(endpoint);
168    let token = token_expression(opts);
169
170    let config_blob = match opts.scope.as_deref() {
171        Some(scope) => format!(
172            "{scope}:registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
173        ),
174        None => format!(
175            "registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
176        ),
177    };
178
179    Ok(ProxyConfig {
180        ecosystem: Ecosystem::Npm,
181        config_blob,
182        canonical_location: home.join(".npmrc"),
183    })
184}
185
186fn emit_pypi(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
187    let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Pypi))?;
188    let endpoint = opts.endpoint.trim_end_matches('/');
189    let host = endpoint_host(endpoint);
190    let token = token_expression(opts);
191
192    let config_blob = format!(
193        "[global]\nindex-url = https://{token}@{host}/pypi/simple/\nextra-index-url =\n\n[install]\ntrusted-host = {host}\n",
194    );
195
196    Ok(ProxyConfig {
197        ecosystem: Ecosystem::Pypi,
198        config_blob,
199        canonical_location: home.join(".config").join("pip").join("pip.conf"),
200    })
201}
202
203fn emit_go(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
204    let endpoint = opts.endpoint.trim_end_matches('/');
205    let token = token_expression(opts);
206
207    // Go has no single canonical config file; emit shell-snippet to be sourced
208    // by ~/.bashrc / ~/.zshrc OR run as `go env -w` invocations.
209    let config_blob = format!(
210        "# CleanLibrary Go proxy — append to your shell config (~/.bashrc, ~/.zshrc, fish config)\n# or run the equivalent `go env -w GOPROXY=...` / `go env -w GOAUTH=...` invocations.\nexport GOPROXY={endpoint}/go,direct\nexport GOAUTH=\"Authorization: Bearer {token}\"\n",
211    );
212
213    Ok(ProxyConfig {
214        ecosystem: Ecosystem::Go,
215        config_blob,
216        // Empty path = no canonical file; caller prints or asks user where to write.
217        canonical_location: PathBuf::new(),
218    })
219}
220
221/// CLEANLIB-373 — cargo / crates.io proxy emit.
222///
223/// Cargo's registry config lives in `~/.cargo/config.toml` (per-user) or
224/// `<workspace>/.cargo/config.toml` (per-workspace). We emit a `[registries]`
225/// entry the user can drop into either — matching the shape cargo documents
226/// at <https://doc.rust-lang.org/cargo/reference/registries.html>. The token
227/// belongs in `~/.cargo/credentials.toml` (never in `config.toml`), so the
228/// snippet also carries the `credentials.toml` block for the same registry
229/// name. Following the `Ecosystem::Go` precedent, `canonical_location` stays
230/// empty because the workspace-vs-user placement is workflow-dependent.
231fn emit_crates(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
232    let endpoint = opts.endpoint.trim_end_matches('/');
233    let token = token_expression(opts);
234
235    // sparse+ prefix makes cargo use the HTTP protocol (stable since 1.68),
236    // not git — which is what a CleanLibrary registry proxy speaks.
237    let config_blob = format!(
238        "# CleanLibrary cargo (crates.io) proxy\n#\n# Registry entry — add to ~/.cargo/config.toml (per-user) or\n# <workspace>/.cargo/config.toml (per-workspace):\n[registries.cleanlibrary]\nindex = \"sparse+{endpoint}/crates/\"\n\n# Token — MUST live in ~/.cargo/credentials.toml (never config.toml):\n[registries.cleanlibrary]\ntoken = \"Bearer {token}\"\n\n# Then publish/install:  cargo <cmd> --registry cleanlibrary\n",
239    );
240
241    Ok(ProxyConfig {
242        ecosystem: Ecosystem::Crates,
243        config_blob,
244        // Empty path = no canonical file; caller prints or asks user where to write.
245        canonical_location: PathBuf::new(),
246    })
247}
248
249/// CLEANLIB-374 — maven / gradle / sbt proxy emit.
250///
251/// Maven reads `~/.m2/settings.xml` for per-user config, and mirrors + auth
252/// belong there (not in a per-project `pom.xml`). We emit the two blocks the
253/// user drops into their existing `<settings>` element — a `<mirror>` that
254/// diverts every request to the CleanLibrary proxy and a `<server>` that
255/// attaches the Bearer token via the standard Maven HTTP-header
256/// configuration property (`httpHeaders`). Following the `Ecosystem::Go` +
257/// `Ecosystem::Crates` precedent, `canonical_location` stays empty because
258/// the per-project `mvn -s` override case is common enough that we do not
259/// silently mutate `~/.m2/settings.xml`.
260fn emit_maven(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
261    let endpoint = opts.endpoint.trim_end_matches('/');
262    let token = token_expression(opts);
263
264    let config_blob = format!(
265        "<!-- CleanLibrary Maven proxy — merge into ~/.m2/settings.xml (or a project-scoped -s file) -->\n<!-- <settings> root element assumed to exist. -->\n<mirrors>\n  <mirror>\n    <id>cleanlibrary</id>\n    <name>CleanLibrary Maven mirror</name>\n    <url>{endpoint}/maven/</url>\n    <mirrorOf>*</mirrorOf>\n  </mirror>\n</mirrors>\n<servers>\n  <server>\n    <id>cleanlibrary</id>\n    <configuration>\n      <httpHeaders>\n        <property>\n          <name>Authorization</name>\n          <value>Bearer {token}</value>\n        </property>\n      </httpHeaders>\n    </configuration>\n  </server>\n</servers>\n",
266    );
267
268    Ok(ProxyConfig {
269        ecosystem: Ecosystem::Maven,
270        config_blob,
271        // Empty path = no canonical file; caller prints or asks user where to write.
272        canonical_location: PathBuf::new(),
273    })
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    fn opts(endpoint: &str) -> EmitOptions {
281        EmitOptions {
282            endpoint: endpoint.to_string(),
283            scope: None,
284            inline_token: false,
285            api_key: None,
286        }
287    }
288
289    #[test]
290    fn ecosystem_parse_vocab_locked() {
291        assert_eq!(Ecosystem::parse("npm"), Some(Ecosystem::Npm));
292        assert_eq!(Ecosystem::parse("pypi"), Some(Ecosystem::Pypi));
293        assert_eq!(Ecosystem::parse("go"), Some(Ecosystem::Go));
294        // CLEANLIB-373 + CLEANLIB-374: crates + maven join the accepted set.
295        assert_eq!(Ecosystem::parse("crates"), Some(Ecosystem::Crates));
296        assert_eq!(Ecosystem::parse("maven"), Some(Ecosystem::Maven));
297        // Matrix §8 forbids these uppercase / off-vocab spellings
298        assert_eq!(Ecosystem::parse("NPM"), None);
299        assert_eq!(Ecosystem::parse("PyPI"), None);
300        assert_eq!(Ecosystem::parse("pip"), None);
301        assert_eq!(Ecosystem::parse("golang"), None);
302        // CLEANLIB-373 + CLEANLIB-374: uppercase / alias spellings still rejected.
303        assert_eq!(Ecosystem::parse("cargo"), None);
304        assert_eq!(Ecosystem::parse("Crates"), None);
305        assert_eq!(Ecosystem::parse("CRATES"), None);
306        assert_eq!(Ecosystem::parse("Maven"), None);
307        assert_eq!(Ecosystem::parse("MAVEN"), None);
308        assert_eq!(Ecosystem::parse("mvn"), None);
309    }
310
311    #[test]
312    fn ecosystem_as_str_roundtrips_lowercase() {
313        // Every accepted ecosystem must round-trip: parse(as_str(e)) == Some(e).
314        // Guards against a future variant added without a lowercase parse arm.
315        for e in Ecosystem::ALL {
316            assert_eq!(Ecosystem::parse(e.as_str()), Some(*e), "roundtrip failed for {:?}", e);
317        }
318    }
319
320    #[test]
321    fn supported_list_includes_crates_and_maven() {
322        // CLEANLIB-373 + CLEANLIB-374 — the human-facing error emitted by
323        // `cleanlib config init` when an ecosystem is unsupported reads
324        // `supported ecosystems: '{supported_list}'`; the list must advertise
325        // the newly-accepted ecosystems so the CLI's error output matches
326        // the CLI's actual accepted set.
327        let list = Ecosystem::supported_list();
328        for expected in &["npm", "pypi", "go", "crates", "maven"] {
329            assert!(
330                list.contains(expected),
331                "supported_list must advertise `{}`; got: {}",
332                expected,
333                list
334            );
335        }
336    }
337
338    #[test]
339    fn npm_emit_shell_expansion_default() {
340        let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
341        assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
342        assert!(blob.contains("//cleanapp.clnstrt.dev/npm/:_authToken=${CLEANLIBRARY_API_KEY}"));
343        assert!(blob.contains("always-auth=true"));
344    }
345
346    #[test]
347    fn npm_emit_with_scope() {
348        let mut o = opts("https://cleanapp.clnstrt.dev");
349        o.scope = Some("@my-org".to_string());
350        let blob = emit_npm(&o).unwrap().config_blob;
351        assert!(blob.contains("@my-org:registry=https://cleanapp.clnstrt.dev/npm/"));
352    }
353
354    #[test]
355    fn npm_emit_inline_token() {
356        let mut o = opts("https://cleanapp.clnstrt.dev");
357        o.inline_token = true;
358        o.api_key = Some("cs_live_smoke".to_string());
359        let blob = emit_npm(&o).unwrap().config_blob;
360        assert!(blob.contains("_authToken=cs_live_smoke"));
361        assert!(!blob.contains("${CLEANLIBRARY_API_KEY}"));
362    }
363
364    #[test]
365    fn pypi_emit_index_url_with_token_in_url() {
366        let blob = emit_pypi(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
367        // pip's index-url puts the token in the URL userinfo position
368        assert!(blob.contains("index-url = https://${CLEANLIBRARY_API_KEY}@cleanapp.clnstrt.dev/pypi/simple/"));
369        assert!(blob.contains("trusted-host = cleanapp.clnstrt.dev"));
370    }
371
372    #[test]
373    fn go_emit_env_form() {
374        let blob = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
375        assert!(blob.contains("export GOPROXY=https://cleanapp.clnstrt.dev/go,direct"));
376        assert!(blob.contains("export GOAUTH=\"Authorization: Bearer ${CLEANLIBRARY_API_KEY}\""));
377    }
378
379    #[test]
380    fn go_emit_has_no_canonical_location() {
381        let cfg = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap();
382        assert!(cfg.canonical_location.as_os_str().is_empty());
383    }
384
385    #[test]
386    fn endpoint_trailing_slash_tolerated() {
387        let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev/")).unwrap().config_blob;
388        // Should NOT have double slash
389        assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
390        assert!(!blob.contains("//npm/"));
391    }
392
393    // CLEANLIB-129 / Jira CLEANLIB-28 — defense-in-depth `emit`-level rejection
394    // of `inline_token=true` when `api_key` is missing or empty/whitespace.
395
396    #[test]
397    fn emit_rejects_inline_token_with_none_api_key() {
398        let mut o = opts("https://cleanapp.clnstrt.dev");
399        o.inline_token = true;
400        o.api_key = None;
401        let err = emit(Ecosystem::Npm, &o).unwrap_err();
402        assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Npm)));
403    }
404
405    #[test]
406    fn emit_rejects_inline_token_with_empty_string_api_key() {
407        let mut o = opts("https://cleanapp.clnstrt.dev");
408        o.inline_token = true;
409        o.api_key = Some(String::new());
410        let err = emit(Ecosystem::Pypi, &o).unwrap_err();
411        assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Pypi)));
412    }
413
414    #[test]
415    fn emit_rejects_inline_token_with_whitespace_only_api_key() {
416        let mut o = opts("https://cleanapp.clnstrt.dev");
417        o.inline_token = true;
418        o.api_key = Some("   \t\n".to_string());
419        let err = emit(Ecosystem::Go, &o).unwrap_err();
420        assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Go)));
421    }
422
423    #[test]
424    fn emit_accepts_inline_token_with_valid_api_key() {
425        let mut o = opts("https://cleanapp.clnstrt.dev");
426        o.inline_token = true;
427        o.api_key = Some("std_001".to_string());
428        let blob = emit(Ecosystem::Npm, &o).unwrap().config_blob;
429        // Verify the legit key reaches the rendered _authToken line.
430        assert!(blob.contains("_authToken=std_001"));
431        // Ensure we did NOT regress to the broken `_authToken=` empty form.
432        assert!(!blob.contains("_authToken=\n"));
433        assert!(!blob.contains("_authToken= "));
434    }
435
436    #[test]
437    fn emit_shell_expansion_path_unaffected_by_empty_key() {
438        // When `inline_token=false`, an empty api_key is fine — the
439        // placeholder resolves at runtime from CLEANLIBRARY_API_KEY env.
440        let mut o = opts("https://cleanapp.clnstrt.dev");
441        o.inline_token = false;
442        o.api_key = None;
443        assert!(emit(Ecosystem::Npm, &o).is_ok());
444        assert!(emit(Ecosystem::Pypi, &o).is_ok());
445        assert!(emit(Ecosystem::Go, &o).is_ok());
446        // CLEANLIB-373 + CLEANLIB-374 — the new variants also emit.
447        assert!(emit(Ecosystem::Crates, &o).is_ok());
448        assert!(emit(Ecosystem::Maven, &o).is_ok());
449    }
450
451    // ── CLEANLIB-373 — cargo / crates.io proxy emit ────────────────────────
452
453    #[test]
454    fn crates_emit_registry_url_and_placeholder_token() {
455        let blob = emit_crates(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
456        // sparse+ HTTP registry index — the wire cargo speaks after 1.68.
457        assert!(
458            blob.contains("index = \"sparse+https://cleanapp.clnstrt.dev/crates/\""),
459            "crates blob missing sparse index; got:\n{blob}"
460        );
461        assert!(
462            blob.contains("[registries.cleanlibrary]"),
463            "crates blob missing [registries.cleanlibrary]; got:\n{blob}"
464        );
465        // Shell-expansion (no --inline-token) → placeholder in the token line.
466        assert!(
467            blob.contains("token = \"Bearer ${CLEANLIBRARY_API_KEY}\""),
468            "crates blob missing token placeholder; got:\n{blob}"
469        );
470    }
471
472    #[test]
473    fn crates_emit_inline_token_embeds_key() {
474        let mut o = opts("https://cleanapp.clnstrt.dev");
475        o.inline_token = true;
476        o.api_key = Some("cs_live_smoke".to_string());
477        let blob = emit_crates(&o).unwrap().config_blob;
478        assert!(
479            blob.contains("token = \"Bearer cs_live_smoke\""),
480            "inline_token must embed the key in the credentials.toml block; got:\n{blob}"
481        );
482        // And never leave the placeholder in place — that would look ok but
483        // silently break auth (sister of the CLEANLIB-129 `_authToken=` empty
484        // regression the npm emit codepath already guards against).
485        assert!(
486            !blob.contains("${CLEANLIBRARY_API_KEY}"),
487            "inline_token blob must NOT retain the placeholder"
488        );
489    }
490
491    #[test]
492    fn crates_emit_has_no_canonical_location() {
493        // Following the Ecosystem::Go precedent — cargo's config placement is
494        // per-user vs per-workspace; the CLI prints the snippet rather than
495        // silently mutating either.
496        let cfg = emit_crates(&opts("https://cleanapp.clnstrt.dev")).unwrap();
497        assert!(cfg.canonical_location.as_os_str().is_empty());
498    }
499
500    // ── CLEANLIB-374 — maven / gradle / sbt proxy emit ─────────────────────
501
502    #[test]
503    fn maven_emit_mirror_url_and_placeholder_token() {
504        let blob = emit_maven(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
505        assert!(
506            blob.contains("<url>https://cleanapp.clnstrt.dev/maven/</url>"),
507            "maven blob missing mirror URL; got:\n{blob}"
508        );
509        assert!(
510            blob.contains("<mirrorOf>*</mirrorOf>"),
511            "maven blob must divert every repo through the CleanLibrary mirror; got:\n{blob}"
512        );
513        assert!(
514            blob.contains("<value>Bearer ${CLEANLIBRARY_API_KEY}</value>"),
515            "maven blob must carry Bearer placeholder in the Authorization header; got:\n{blob}"
516        );
517    }
518
519    #[test]
520    fn maven_emit_inline_token_embeds_key() {
521        let mut o = opts("https://cleanapp.clnstrt.dev");
522        o.inline_token = true;
523        o.api_key = Some("cs_live_smoke".to_string());
524        let blob = emit_maven(&o).unwrap().config_blob;
525        assert!(
526            blob.contains("<value>Bearer cs_live_smoke</value>"),
527            "inline_token must embed the key in the httpHeaders block; got:\n{blob}"
528        );
529        assert!(
530            !blob.contains("${CLEANLIBRARY_API_KEY}"),
531            "inline_token blob must NOT retain the placeholder"
532        );
533    }
534
535    #[test]
536    fn maven_emit_has_no_canonical_location() {
537        // ~/.m2/settings.xml is common but the per-project `mvn -s` override
538        // pattern is common enough that we do not silently mutate it.
539        let cfg = emit_maven(&opts("https://cleanapp.clnstrt.dev")).unwrap();
540        assert!(cfg.canonical_location.as_os_str().is_empty());
541    }
542
543    #[test]
544    fn crates_and_maven_endpoint_trailing_slash_tolerated() {
545        // Sister of the existing npm test — the trim_end_matches('/') defence
546        // must fire for the new ecosystems too so `--endpoint https://x/`
547        // doesn't produce `//crates/` / `//maven/` in the emitted config.
548        let crates_blob = emit_crates(&opts("https://cleanapp.clnstrt.dev/"))
549            .unwrap()
550            .config_blob;
551        assert!(crates_blob.contains("sparse+https://cleanapp.clnstrt.dev/crates/"));
552        assert!(!crates_blob.contains("//crates/"));
553
554        let maven_blob = emit_maven(&opts("https://cleanapp.clnstrt.dev/"))
555            .unwrap()
556            .config_blob;
557        assert!(maven_blob.contains("<url>https://cleanapp.clnstrt.dev/maven/</url>"));
558        assert!(!maven_blob.contains("//maven/"));
559    }
560}