1use std::path::PathBuf;
16
17use thiserror::Error;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Ecosystem {
31 Npm,
32 Pypi,
33 Go,
34 Crates,
36 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 "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#[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 #[error(
101 "inline_token requires a non-empty api_key (would emit broken auth header for {0:?})"
102 )]
103 InlineTokenEmpty(Ecosystem),
104}
105
106pub struct EmitOptions {
108 pub endpoint: String,
111 pub scope: Option<String>,
113 pub inline_token: bool,
117 pub api_key: Option<String>,
119}
120
121pub fn emit(ecosystem: Ecosystem, opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
122 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 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 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 canonical_location: PathBuf::new(),
218 })
219}
220
221fn emit_crates(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
232 let endpoint = opts.endpoint.trim_end_matches('/');
233 let token = token_expression(opts);
234
235 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 canonical_location: PathBuf::new(),
246 })
247}
248
249fn 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 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 assert_eq!(Ecosystem::parse("crates"), Some(Ecosystem::Crates));
296 assert_eq!(Ecosystem::parse("maven"), Some(Ecosystem::Maven));
297 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 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 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 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 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 assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
390 assert!(!blob.contains("//npm/"));
391 }
392
393 #[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 assert!(blob.contains("_authToken=std_001"));
431 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 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 assert!(emit(Ecosystem::Crates, &o).is_ok());
448 assert!(emit(Ecosystem::Maven, &o).is_ok());
449 }
450
451 #[test]
454 fn crates_emit_registry_url_and_placeholder_token() {
455 let blob = emit_crates(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
456 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 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 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 let cfg = emit_crates(&opts("https://cleanapp.clnstrt.dev")).unwrap();
497 assert!(cfg.canonical_location.as_os_str().is_empty());
498 }
499
500 #[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 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 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}