//! Per-ecosystem proxy-config emit per [per-ecosystem-proxy-config-emit-format
//! decision 2026-05-20]. Phase 1 Tier A: npm + pypi + go.
//!
//! CLEANLIB-373 + CLEANLIB-374 (cycle-18): extend the accepted-ecosystem list
//! with `crates` (cargo) and `maven`. The 8-ecosystem catalog the wire supports
//! today (npm, pypi, go, crates, maven, nuget, rubygems, composer) was already
//! locked in `cli_matrix` fixtures + the ecosystem-specific crates
//! (`cleanlib-ecosystem-*`); `config init` was still refusing two of the eight
//! at the CLI validation layer despite the App backend accepting them.
//!
//! Each emit function returns a [`ProxyConfig`] holding the config-blob text +
//! the canonical local path where the file would be written. Callers
//! (typically [`cleanlib config init`]) decide whether to write or print.
use std::path::PathBuf;
use thiserror::Error;
/// Locked vocabulary per matrix §8 — ecosystem identifiers are always lowercase.
///
/// CLEANLIB-373 + CLEANLIB-374 close: `Crates` and `Maven` join the accepted
/// set. Both emit shell-snippet form (no `canonical_location`) because the
/// canonical config file placement is workflow-dependent — cargo per-user
/// (`~/.cargo/config.toml`) vs per-workspace, maven per-user
/// (`~/.m2/settings.xml`) vs per-project `mvn -s`. `config init` prints the
/// snippet + the recommended file path in the header line rather than
/// silently mutating either default. Sister of the `Ecosystem::Go` shape
/// which follows the same shell-snippet pattern for GOPROXY / GOAUTH.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ecosystem {
Npm,
Pypi,
Go,
/// CLEANLIB-373 — cargo / crates.io registry (rustaceans).
Crates,
/// CLEANLIB-374 — Maven Central (JVM: maven/gradle/sbt).
Maven,
}
impl Ecosystem {
pub fn parse(s: &str) -> Option<Self> {
match s {
"npm" => Some(Self::Npm),
"pypi" => Some(Self::Pypi),
"go" => Some(Self::Go),
// CLEANLIB-373 + CLEANLIB-374 — lowercase vocabulary only, matches
// the ecosystem identifier the App wire uses (never `cargo` /
// `mvn` / `MAVEN` — matrix §8 locks lowercase canonical names).
"crates" => Some(Self::Crates),
"maven" => Some(Self::Maven),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Npm => "npm",
Self::Pypi => "pypi",
Self::Go => "go",
Self::Crates => "crates",
Self::Maven => "maven",
}
}
}
impl Ecosystem {
pub const ALL: &'static [Ecosystem] = &[
Ecosystem::Npm,
Ecosystem::Pypi,
Ecosystem::Go,
Ecosystem::Crates,
Ecosystem::Maven,
];
pub fn supported_list() -> String {
Self::ALL
.iter()
.map(|e| e.as_str())
.collect::<Vec<_>>()
.join(", ")
}
}
/// Emitted config: blob is the literal file/shell content; canonical_location
/// is the default write path (or empty for ecosystems without a single
/// canonical file like Go's `GOPROXY` env).
#[derive(Debug)]
pub struct ProxyConfig {
pub ecosystem: Ecosystem,
pub config_blob: String,
pub canonical_location: PathBuf,
}
#[derive(Debug, Error)]
pub enum ProxyConfigError {
#[error("home directory not discoverable; cannot resolve canonical location for {0:?}")]
HomeDirUnavailable(Ecosystem),
/// CLEANLIB-129 / Jira CLEANLIB-28: refuse `inline_token=true` when the
/// provided `api_key` is missing or empty/whitespace-only. Pre-fix
/// behaviour emitted `_authToken=` (empty) → broken `.npmrc`.
#[error(
"inline_token requires a non-empty api_key (would emit broken auth header for {0:?})"
)]
InlineTokenEmpty(Ecosystem),
}
/// Options controlling emit shape.
pub struct EmitOptions {
/// Base App endpoint, e.g., `https://cleanapp.clnstrt.dev`. Trailing slash
/// is tolerated.
pub endpoint: String,
/// Optional npm scope (e.g., `@my-org`). Only applies to npm emit.
pub scope: Option<String>,
/// If `true`, embed `api_key` literal in the emitted config (use for CI
/// runners without env-var support). If `false`, emit
/// `${CLEANLIBRARY_API_KEY}` shell-expansion (default; preferred).
pub inline_token: bool,
/// API-key value to embed when `inline_token = true`. Ignored otherwise.
pub api_key: Option<String>,
}
pub fn emit(ecosystem: Ecosystem, opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
// CLEANLIB-129 / Jira CLEANLIB-28 defense-in-depth: even when the CLI
// forgot to validate, never let the proxy emit `_authToken=` (empty)
// out the back. Sister of the `cleanlib config init --inline-token`
// pre-check in `cleanlib-cli/src/commands/config_init.rs`.
if opts.inline_token {
let has_usable_key = opts
.api_key
.as_deref()
.map(|k| !k.trim().is_empty())
.unwrap_or(false);
if !has_usable_key {
return Err(ProxyConfigError::InlineTokenEmpty(ecosystem));
}
}
match ecosystem {
Ecosystem::Npm => emit_npm(opts),
Ecosystem::Pypi => emit_pypi(opts),
Ecosystem::Go => emit_go(opts),
Ecosystem::Crates => emit_crates(opts),
Ecosystem::Maven => emit_maven(opts),
}
}
fn token_expression(opts: &EmitOptions) -> String {
if opts.inline_token {
// `emit` guarantees `api_key` is Some(non_empty) when
// `inline_token=true`; this default is dead code on the success
// path and only reachable via direct internal calls.
opts.api_key.clone().unwrap_or_default()
} else {
"${CLEANLIBRARY_API_KEY}".to_string()
}
}
fn endpoint_host(endpoint: &str) -> &str {
endpoint
.trim_end_matches('/')
.trim_start_matches("https://")
.trim_start_matches("http://")
}
fn emit_npm(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Npm))?;
let endpoint = opts.endpoint.trim_end_matches('/');
let registry_url = format!("{}/npm/", endpoint);
let host = endpoint_host(endpoint);
let token = token_expression(opts);
let config_blob = match opts.scope.as_deref() {
Some(scope) => format!(
"{scope}:registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
),
None => format!(
"registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
),
};
Ok(ProxyConfig {
ecosystem: Ecosystem::Npm,
config_blob,
canonical_location: home.join(".npmrc"),
})
}
fn emit_pypi(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Pypi))?;
let endpoint = opts.endpoint.trim_end_matches('/');
let host = endpoint_host(endpoint);
let token = token_expression(opts);
let config_blob = format!(
"[global]\nindex-url = https://{token}@{host}/pypi/simple/\nextra-index-url =\n\n[install]\ntrusted-host = {host}\n",
);
Ok(ProxyConfig {
ecosystem: Ecosystem::Pypi,
config_blob,
canonical_location: home.join(".config").join("pip").join("pip.conf"),
})
}
fn emit_go(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
let endpoint = opts.endpoint.trim_end_matches('/');
let token = token_expression(opts);
// Go has no single canonical config file; emit shell-snippet to be sourced
// by ~/.bashrc / ~/.zshrc OR run as `go env -w` invocations.
let config_blob = format!(
"# 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",
);
Ok(ProxyConfig {
ecosystem: Ecosystem::Go,
config_blob,
// Empty path = no canonical file; caller prints or asks user where to write.
canonical_location: PathBuf::new(),
})
}
/// CLEANLIB-373 — cargo / crates.io proxy emit.
///
/// Cargo's registry config lives in `~/.cargo/config.toml` (per-user) or
/// `<workspace>/.cargo/config.toml` (per-workspace). We emit a `[registries]`
/// entry the user can drop into either — matching the shape cargo documents
/// at <https://doc.rust-lang.org/cargo/reference/registries.html>. The token
/// belongs in `~/.cargo/credentials.toml` (never in `config.toml`), so the
/// snippet also carries the `credentials.toml` block for the same registry
/// name. Following the `Ecosystem::Go` precedent, `canonical_location` stays
/// empty because the workspace-vs-user placement is workflow-dependent.
fn emit_crates(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
let endpoint = opts.endpoint.trim_end_matches('/');
let token = token_expression(opts);
// sparse+ prefix makes cargo use the HTTP protocol (stable since 1.68),
// not git — which is what a CleanLibrary registry proxy speaks.
let config_blob = format!(
"# 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",
);
Ok(ProxyConfig {
ecosystem: Ecosystem::Crates,
config_blob,
// Empty path = no canonical file; caller prints or asks user where to write.
canonical_location: PathBuf::new(),
})
}
/// CLEANLIB-374 — maven / gradle / sbt proxy emit.
///
/// Maven reads `~/.m2/settings.xml` for per-user config, and mirrors + auth
/// belong there (not in a per-project `pom.xml`). We emit the two blocks the
/// user drops into their existing `<settings>` element — a `<mirror>` that
/// diverts every request to the CleanLibrary proxy and a `<server>` that
/// attaches the Bearer token via the standard Maven HTTP-header
/// configuration property (`httpHeaders`). Following the `Ecosystem::Go` +
/// `Ecosystem::Crates` precedent, `canonical_location` stays empty because
/// the per-project `mvn -s` override case is common enough that we do not
/// silently mutate `~/.m2/settings.xml`.
fn emit_maven(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
let endpoint = opts.endpoint.trim_end_matches('/');
let token = token_expression(opts);
let config_blob = format!(
"<!-- 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",
);
Ok(ProxyConfig {
ecosystem: Ecosystem::Maven,
config_blob,
// Empty path = no canonical file; caller prints or asks user where to write.
canonical_location: PathBuf::new(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn opts(endpoint: &str) -> EmitOptions {
EmitOptions {
endpoint: endpoint.to_string(),
scope: None,
inline_token: false,
api_key: None,
}
}
#[test]
fn ecosystem_parse_vocab_locked() {
assert_eq!(Ecosystem::parse("npm"), Some(Ecosystem::Npm));
assert_eq!(Ecosystem::parse("pypi"), Some(Ecosystem::Pypi));
assert_eq!(Ecosystem::parse("go"), Some(Ecosystem::Go));
// CLEANLIB-373 + CLEANLIB-374: crates + maven join the accepted set.
assert_eq!(Ecosystem::parse("crates"), Some(Ecosystem::Crates));
assert_eq!(Ecosystem::parse("maven"), Some(Ecosystem::Maven));
// Matrix §8 forbids these uppercase / off-vocab spellings
assert_eq!(Ecosystem::parse("NPM"), None);
assert_eq!(Ecosystem::parse("PyPI"), None);
assert_eq!(Ecosystem::parse("pip"), None);
assert_eq!(Ecosystem::parse("golang"), None);
// CLEANLIB-373 + CLEANLIB-374: uppercase / alias spellings still rejected.
assert_eq!(Ecosystem::parse("cargo"), None);
assert_eq!(Ecosystem::parse("Crates"), None);
assert_eq!(Ecosystem::parse("CRATES"), None);
assert_eq!(Ecosystem::parse("Maven"), None);
assert_eq!(Ecosystem::parse("MAVEN"), None);
assert_eq!(Ecosystem::parse("mvn"), None);
}
#[test]
fn ecosystem_as_str_roundtrips_lowercase() {
// Every accepted ecosystem must round-trip: parse(as_str(e)) == Some(e).
// Guards against a future variant added without a lowercase parse arm.
for e in Ecosystem::ALL {
assert_eq!(Ecosystem::parse(e.as_str()), Some(*e), "roundtrip failed for {:?}", e);
}
}
#[test]
fn supported_list_includes_crates_and_maven() {
// CLEANLIB-373 + CLEANLIB-374 — the human-facing error emitted by
// `cleanlib config init` when an ecosystem is unsupported reads
// `supported ecosystems: '{supported_list}'`; the list must advertise
// the newly-accepted ecosystems so the CLI's error output matches
// the CLI's actual accepted set.
let list = Ecosystem::supported_list();
for expected in &["npm", "pypi", "go", "crates", "maven"] {
assert!(
list.contains(expected),
"supported_list must advertise `{}`; got: {}",
expected,
list
);
}
}
#[test]
fn npm_emit_shell_expansion_default() {
let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
assert!(blob.contains("//cleanapp.clnstrt.dev/npm/:_authToken=${CLEANLIBRARY_API_KEY}"));
assert!(blob.contains("always-auth=true"));
}
#[test]
fn npm_emit_with_scope() {
let mut o = opts("https://cleanapp.clnstrt.dev");
o.scope = Some("@my-org".to_string());
let blob = emit_npm(&o).unwrap().config_blob;
assert!(blob.contains("@my-org:registry=https://cleanapp.clnstrt.dev/npm/"));
}
#[test]
fn npm_emit_inline_token() {
let mut o = opts("https://cleanapp.clnstrt.dev");
o.inline_token = true;
o.api_key = Some("cs_live_smoke".to_string());
let blob = emit_npm(&o).unwrap().config_blob;
assert!(blob.contains("_authToken=cs_live_smoke"));
assert!(!blob.contains("${CLEANLIBRARY_API_KEY}"));
}
#[test]
fn pypi_emit_index_url_with_token_in_url() {
let blob = emit_pypi(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
// pip's index-url puts the token in the URL userinfo position
assert!(blob.contains("index-url = https://${CLEANLIBRARY_API_KEY}@cleanapp.clnstrt.dev/pypi/simple/"));
assert!(blob.contains("trusted-host = cleanapp.clnstrt.dev"));
}
#[test]
fn go_emit_env_form() {
let blob = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
assert!(blob.contains("export GOPROXY=https://cleanapp.clnstrt.dev/go,direct"));
assert!(blob.contains("export GOAUTH=\"Authorization: Bearer ${CLEANLIBRARY_API_KEY}\""));
}
#[test]
fn go_emit_has_no_canonical_location() {
let cfg = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap();
assert!(cfg.canonical_location.as_os_str().is_empty());
}
#[test]
fn endpoint_trailing_slash_tolerated() {
let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev/")).unwrap().config_blob;
// Should NOT have double slash
assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
assert!(!blob.contains("//npm/"));
}
// CLEANLIB-129 / Jira CLEANLIB-28 — defense-in-depth `emit`-level rejection
// of `inline_token=true` when `api_key` is missing or empty/whitespace.
#[test]
fn emit_rejects_inline_token_with_none_api_key() {
let mut o = opts("https://cleanapp.clnstrt.dev");
o.inline_token = true;
o.api_key = None;
let err = emit(Ecosystem::Npm, &o).unwrap_err();
assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Npm)));
}
#[test]
fn emit_rejects_inline_token_with_empty_string_api_key() {
let mut o = opts("https://cleanapp.clnstrt.dev");
o.inline_token = true;
o.api_key = Some(String::new());
let err = emit(Ecosystem::Pypi, &o).unwrap_err();
assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Pypi)));
}
#[test]
fn emit_rejects_inline_token_with_whitespace_only_api_key() {
let mut o = opts("https://cleanapp.clnstrt.dev");
o.inline_token = true;
o.api_key = Some(" \t\n".to_string());
let err = emit(Ecosystem::Go, &o).unwrap_err();
assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Go)));
}
#[test]
fn emit_accepts_inline_token_with_valid_api_key() {
let mut o = opts("https://cleanapp.clnstrt.dev");
o.inline_token = true;
o.api_key = Some("std_001".to_string());
let blob = emit(Ecosystem::Npm, &o).unwrap().config_blob;
// Verify the legit key reaches the rendered _authToken line.
assert!(blob.contains("_authToken=std_001"));
// Ensure we did NOT regress to the broken `_authToken=` empty form.
assert!(!blob.contains("_authToken=\n"));
assert!(!blob.contains("_authToken= "));
}
#[test]
fn emit_shell_expansion_path_unaffected_by_empty_key() {
// When `inline_token=false`, an empty api_key is fine — the
// placeholder resolves at runtime from CLEANLIBRARY_API_KEY env.
let mut o = opts("https://cleanapp.clnstrt.dev");
o.inline_token = false;
o.api_key = None;
assert!(emit(Ecosystem::Npm, &o).is_ok());
assert!(emit(Ecosystem::Pypi, &o).is_ok());
assert!(emit(Ecosystem::Go, &o).is_ok());
// CLEANLIB-373 + CLEANLIB-374 — the new variants also emit.
assert!(emit(Ecosystem::Crates, &o).is_ok());
assert!(emit(Ecosystem::Maven, &o).is_ok());
}
// ── CLEANLIB-373 — cargo / crates.io proxy emit ────────────────────────
#[test]
fn crates_emit_registry_url_and_placeholder_token() {
let blob = emit_crates(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
// sparse+ HTTP registry index — the wire cargo speaks after 1.68.
assert!(
blob.contains("index = \"sparse+https://cleanapp.clnstrt.dev/crates/\""),
"crates blob missing sparse index; got:\n{blob}"
);
assert!(
blob.contains("[registries.cleanlibrary]"),
"crates blob missing [registries.cleanlibrary]; got:\n{blob}"
);
// Shell-expansion (no --inline-token) → placeholder in the token line.
assert!(
blob.contains("token = \"Bearer ${CLEANLIBRARY_API_KEY}\""),
"crates blob missing token placeholder; got:\n{blob}"
);
}
#[test]
fn crates_emit_inline_token_embeds_key() {
let mut o = opts("https://cleanapp.clnstrt.dev");
o.inline_token = true;
o.api_key = Some("cs_live_smoke".to_string());
let blob = emit_crates(&o).unwrap().config_blob;
assert!(
blob.contains("token = \"Bearer cs_live_smoke\""),
"inline_token must embed the key in the credentials.toml block; got:\n{blob}"
);
// And never leave the placeholder in place — that would look ok but
// silently break auth (sister of the CLEANLIB-129 `_authToken=` empty
// regression the npm emit codepath already guards against).
assert!(
!blob.contains("${CLEANLIBRARY_API_KEY}"),
"inline_token blob must NOT retain the placeholder"
);
}
#[test]
fn crates_emit_has_no_canonical_location() {
// Following the Ecosystem::Go precedent — cargo's config placement is
// per-user vs per-workspace; the CLI prints the snippet rather than
// silently mutating either.
let cfg = emit_crates(&opts("https://cleanapp.clnstrt.dev")).unwrap();
assert!(cfg.canonical_location.as_os_str().is_empty());
}
// ── CLEANLIB-374 — maven / gradle / sbt proxy emit ─────────────────────
#[test]
fn maven_emit_mirror_url_and_placeholder_token() {
let blob = emit_maven(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
assert!(
blob.contains("<url>https://cleanapp.clnstrt.dev/maven/</url>"),
"maven blob missing mirror URL; got:\n{blob}"
);
assert!(
blob.contains("<mirrorOf>*</mirrorOf>"),
"maven blob must divert every repo through the CleanLibrary mirror; got:\n{blob}"
);
assert!(
blob.contains("<value>Bearer ${CLEANLIBRARY_API_KEY}</value>"),
"maven blob must carry Bearer placeholder in the Authorization header; got:\n{blob}"
);
}
#[test]
fn maven_emit_inline_token_embeds_key() {
let mut o = opts("https://cleanapp.clnstrt.dev");
o.inline_token = true;
o.api_key = Some("cs_live_smoke".to_string());
let blob = emit_maven(&o).unwrap().config_blob;
assert!(
blob.contains("<value>Bearer cs_live_smoke</value>"),
"inline_token must embed the key in the httpHeaders block; got:\n{blob}"
);
assert!(
!blob.contains("${CLEANLIBRARY_API_KEY}"),
"inline_token blob must NOT retain the placeholder"
);
}
#[test]
fn maven_emit_has_no_canonical_location() {
// ~/.m2/settings.xml is common but the per-project `mvn -s` override
// pattern is common enough that we do not silently mutate it.
let cfg = emit_maven(&opts("https://cleanapp.clnstrt.dev")).unwrap();
assert!(cfg.canonical_location.as_os_str().is_empty());
}
#[test]
fn crates_and_maven_endpoint_trailing_slash_tolerated() {
// Sister of the existing npm test — the trim_end_matches('/') defence
// must fire for the new ecosystems too so `--endpoint https://x/`
// doesn't produce `//crates/` / `//maven/` in the emitted config.
let crates_blob = emit_crates(&opts("https://cleanapp.clnstrt.dev/"))
.unwrap()
.config_blob;
assert!(crates_blob.contains("sparse+https://cleanapp.clnstrt.dev/crates/"));
assert!(!crates_blob.contains("//crates/"));
let maven_blob = emit_maven(&opts("https://cleanapp.clnstrt.dev/"))
.unwrap()
.config_blob;
assert!(maven_blob.contains("<url>https://cleanapp.clnstrt.dev/maven/</url>"));
assert!(!maven_blob.contains("//maven/"));
}
}