1use std::path::PathBuf;
16
17use thiserror::Error;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Ecosystem {
48 Npm,
49 Pypi,
50 Go,
51 Crates,
53 Maven,
55 Nuget,
57}
58
59impl Ecosystem {
60 pub fn parse(s: &str) -> Option<Self> {
61 match s {
62 "npm" => Some(Self::Npm),
63 "pypi" => Some(Self::Pypi),
64 "go" => Some(Self::Go),
65 "crates" => Some(Self::Crates),
69 "maven" => Some(Self::Maven),
70 "nuget" => Some(Self::Nuget),
73 _ => None,
74 }
75 }
76 pub fn as_str(self) -> &'static str {
77 match self {
78 Self::Npm => "npm",
79 Self::Pypi => "pypi",
80 Self::Go => "go",
81 Self::Crates => "crates",
82 Self::Maven => "maven",
83 Self::Nuget => "nuget",
84 }
85 }
86}
87
88impl Ecosystem {
89 pub const ALL: &'static [Ecosystem] = &[
90 Ecosystem::Npm,
91 Ecosystem::Pypi,
92 Ecosystem::Go,
93 Ecosystem::Crates,
94 Ecosystem::Maven,
95 Ecosystem::Nuget,
96 ];
97
98 pub fn supported_list() -> String {
99 Self::ALL
100 .iter()
101 .map(|e| e.as_str())
102 .collect::<Vec<_>>()
103 .join(", ")
104 }
105}
106
107#[derive(Debug)]
111pub struct ProxyConfig {
112 pub ecosystem: Ecosystem,
113 pub config_blob: String,
114 pub canonical_location: PathBuf,
115}
116
117#[derive(Debug, Error)]
118pub enum ProxyConfigError {
119 #[error("home directory not discoverable; cannot resolve canonical location for {0:?}")]
120 HomeDirUnavailable(Ecosystem),
121 #[error(
125 "inline_token requires a non-empty api_key (would emit broken auth header for {0:?})"
126 )]
127 InlineTokenEmpty(Ecosystem),
128 #[error(
137 "pypi config emit requires a resolved API key or --emit-netrc — pip does not \
138 expand ${{CLEANLIBRARY_API_KEY}} in pip.conf, so a placeholder would 401 every \
139 request. Run `cleanlib login --api-key <KEY>` first (or set CLEANLIBRARY_API_KEY \
140 in the env before `cleanlib config init`)."
141 )]
142 PypiRequiresResolvedKey,
143}
144
145pub struct EmitOptions {
147 pub endpoint: String,
150 pub scope: Option<String>,
152 pub inline_token: bool,
161 pub api_key: Option<String>,
165 pub emit_netrc: bool,
173}
174
175pub fn emit(ecosystem: Ecosystem, opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
176 if opts.inline_token {
181 let has_usable_key = opts
182 .api_key
183 .as_deref()
184 .map(|k| !k.trim().is_empty())
185 .unwrap_or(false);
186 if !has_usable_key {
187 return Err(ProxyConfigError::InlineTokenEmpty(ecosystem));
188 }
189 }
190 match ecosystem {
191 Ecosystem::Npm => emit_npm(opts),
192 Ecosystem::Pypi => emit_pypi(opts),
193 Ecosystem::Go => emit_go(opts),
194 Ecosystem::Crates => emit_crates(opts),
195 Ecosystem::Maven => emit_maven(opts),
196 Ecosystem::Nuget => emit_nuget(opts),
197 }
198}
199
200fn token_expression(opts: &EmitOptions) -> String {
201 if opts.inline_token {
202 opts.api_key.clone().unwrap_or_default()
206 } else {
207 "${CLEANLIBRARY_API_KEY}".to_string()
208 }
209}
210
211fn endpoint_host(endpoint: &str) -> &str {
212 endpoint
213 .trim_end_matches('/')
214 .trim_start_matches("https://")
215 .trim_start_matches("http://")
216}
217
218fn emit_npm(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
219 let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Npm))?;
220 let endpoint = opts.endpoint.trim_end_matches('/');
221 let registry_url = format!("{}/npm/", endpoint);
222 let host = endpoint_host(endpoint);
223 let token = token_expression(opts);
224
225 let config_blob = match opts.scope.as_deref() {
226 Some(scope) => format!(
227 "{scope}:registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
228 ),
229 None => format!(
230 "registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
231 ),
232 };
233
234 Ok(ProxyConfig {
235 ecosystem: Ecosystem::Npm,
236 config_blob,
237 canonical_location: home.join(".npmrc"),
238 })
239}
240
241fn percent_encode_userinfo(s: &str) -> String {
248 let mut out = String::with_capacity(s.len());
249 for byte in s.as_bytes() {
250 let c = *byte;
251 if c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.' | b'~') {
252 out.push(c as char);
253 } else {
254 out.push_str(&format!("%{:02X}", c));
255 }
256 }
257 out
258}
259
260fn emit_pypi(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
284 let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Pypi))?;
285 let endpoint = opts.endpoint.trim_end_matches('/');
286 let host = endpoint_host(endpoint);
287
288 let api_key = opts
291 .api_key
292 .as_deref()
293 .map(str::trim)
294 .filter(|k| !k.is_empty());
295
296 if opts.emit_netrc {
297 let key = api_key.ok_or(ProxyConfigError::PypiRequiresResolvedKey)?;
303 let pip_conf = format!(
304 "[global]\nindex-url = https://{host}/pypi/simple/\nextra-index-url =\n\n[install]\ntrusted-host = {host}\n",
305 );
306 let netrc_dest = home.join(".netrc");
307 let netrc_block = format!(
312 "machine {host}\n login {key}\n password \"\"\n",
313 );
314 let config_blob = format!(
315 "# === CleanLibrary pypi proxy (--emit-netrc) ===\n\
316 # Two-file emit: pip.conf carries no credentials; ~/.netrc carries the key.\n\
317 # pip reads ~/.netrc natively — no ${{VAR}} expansion needed (CLEANLIB-758).\n\
318 #\n\
319 # --- write this half to ~/.config/pip/pip.conf ---\n\
320 {pip_conf}\n\
321 # --- append this half to {netrc_dest_display} (chmod 600) ---\n\
322 {netrc_block}",
323 netrc_dest_display = netrc_dest.display(),
324 );
325 return Ok(ProxyConfig {
326 ecosystem: Ecosystem::Pypi,
327 config_blob,
328 canonical_location: PathBuf::new(),
330 });
331 }
332
333 let key = api_key.ok_or(ProxyConfigError::PypiRequiresResolvedKey)?;
336 let encoded_key = percent_encode_userinfo(key);
337 let config_blob = format!(
338 "[global]\nindex-url = https://{encoded_key}@{host}/pypi/simple/\nextra-index-url =\n\n[install]\ntrusted-host = {host}\n",
339 );
340
341 Ok(ProxyConfig {
342 ecosystem: Ecosystem::Pypi,
343 config_blob,
344 canonical_location: home.join(".config").join("pip").join("pip.conf"),
345 })
346}
347
348fn emit_go(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
349 let endpoint = opts.endpoint.trim_end_matches('/');
350 let token = token_expression(opts);
351
352 let config_blob = format!(
355 "# 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",
356 );
357
358 Ok(ProxyConfig {
359 ecosystem: Ecosystem::Go,
360 config_blob,
361 canonical_location: PathBuf::new(),
363 })
364}
365
366fn emit_crates(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
377 let endpoint = opts.endpoint.trim_end_matches('/');
378 let token = token_expression(opts);
379
380 let config_blob = format!(
383 "# 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",
384 );
385
386 Ok(ProxyConfig {
387 ecosystem: Ecosystem::Crates,
388 config_blob,
389 canonical_location: PathBuf::new(),
391 })
392}
393
394fn emit_maven(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
406 let endpoint = opts.endpoint.trim_end_matches('/');
407 let token = token_expression(opts);
408
409 let config_blob = format!(
410 "<!-- 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",
411 );
412
413 Ok(ProxyConfig {
414 ecosystem: Ecosystem::Maven,
415 config_blob,
416 canonical_location: PathBuf::new(),
418 })
419}
420
421fn emit_nuget(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
443 let endpoint = opts.endpoint.trim_end_matches('/');
444 let token = token_expression(opts);
445
446 let config_blob = format!(
447 "<!-- CleanLibrary NuGet proxy — merge into NuGet.Config (per-project, or\n ~/.nuget/NuGet/NuGet.Config / %APPDATA%\\NuGet\\NuGet.Config per-user) -->\n<!-- <configuration> root element assumed to exist. -->\n<packageSources>\n <add key=\"cleanlibrary\" value=\"{endpoint}/nuget/v3/index.json\" />\n</packageSources>\n<packageSourceCredentials>\n <cleanlibrary>\n <add key=\"Username\" value=\"cleanlibrary\" />\n <add key=\"ClearTextPassword\" value=\"{token}\" />\n </cleanlibrary>\n</packageSourceCredentials>\n",
448 );
449
450 Ok(ProxyConfig {
451 ecosystem: Ecosystem::Nuget,
452 config_blob,
453 canonical_location: PathBuf::new(),
455 })
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 fn opts(endpoint: &str) -> EmitOptions {
463 EmitOptions {
464 endpoint: endpoint.to_string(),
465 scope: None,
466 inline_token: false,
467 api_key: None,
468 emit_netrc: false,
469 }
470 }
471
472 fn opts_with_key(endpoint: &str, key: &str) -> EmitOptions {
477 EmitOptions {
478 endpoint: endpoint.to_string(),
479 scope: None,
480 inline_token: false,
481 api_key: Some(key.to_string()),
482 emit_netrc: false,
483 }
484 }
485
486 #[test]
487 fn ecosystem_parse_vocab_locked() {
488 assert_eq!(Ecosystem::parse("npm"), Some(Ecosystem::Npm));
489 assert_eq!(Ecosystem::parse("pypi"), Some(Ecosystem::Pypi));
490 assert_eq!(Ecosystem::parse("go"), Some(Ecosystem::Go));
491 assert_eq!(Ecosystem::parse("crates"), Some(Ecosystem::Crates));
493 assert_eq!(Ecosystem::parse("maven"), Some(Ecosystem::Maven));
494 assert_eq!(Ecosystem::parse("NPM"), None);
496 assert_eq!(Ecosystem::parse("PyPI"), None);
497 assert_eq!(Ecosystem::parse("pip"), None);
498 assert_eq!(Ecosystem::parse("golang"), None);
499 assert_eq!(Ecosystem::parse("cargo"), None);
501 assert_eq!(Ecosystem::parse("Crates"), None);
502 assert_eq!(Ecosystem::parse("CRATES"), None);
503 assert_eq!(Ecosystem::parse("Maven"), None);
504 assert_eq!(Ecosystem::parse("MAVEN"), None);
505 assert_eq!(Ecosystem::parse("mvn"), None);
506 }
507
508 #[test]
509 fn ecosystem_as_str_roundtrips_lowercase() {
510 for e in Ecosystem::ALL {
513 assert_eq!(Ecosystem::parse(e.as_str()), Some(*e), "roundtrip failed for {:?}", e);
514 }
515 }
516
517 #[test]
518 fn supported_list_includes_crates_and_maven() {
519 let list = Ecosystem::supported_list();
525 for expected in &["npm", "pypi", "go", "crates", "maven"] {
526 assert!(
527 list.contains(expected),
528 "supported_list must advertise `{}`; got: {}",
529 expected,
530 list
531 );
532 }
533 }
534
535 #[test]
536 fn npm_emit_shell_expansion_default() {
537 let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
538 assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
539 assert!(blob.contains("//cleanapp.clnstrt.dev/npm/:_authToken=${CLEANLIBRARY_API_KEY}"));
540 assert!(blob.contains("always-auth=true"));
541 }
542
543 #[test]
544 fn npm_emit_with_scope() {
545 let mut o = opts("https://cleanapp.clnstrt.dev");
546 o.scope = Some("@my-org".to_string());
547 let blob = emit_npm(&o).unwrap().config_blob;
548 assert!(blob.contains("@my-org:registry=https://cleanapp.clnstrt.dev/npm/"));
549 }
550
551 #[test]
552 fn npm_emit_inline_token() {
553 let mut o = opts("https://cleanapp.clnstrt.dev");
554 o.inline_token = true;
555 o.api_key = Some("cs_live_smoke".to_string());
556 let blob = emit_npm(&o).unwrap().config_blob;
557 assert!(blob.contains("_authToken=cs_live_smoke"));
558 assert!(!blob.contains("${CLEANLIBRARY_API_KEY}"));
559 }
560
561 #[test]
569 fn pypi_emit_default_embeds_resolved_key_in_url_userinfo() {
570 let blob = emit_pypi(&opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc"))
571 .unwrap()
572 .config_blob;
573 assert!(
574 blob.contains("index-url = https://cs_live_abc@cleanapp.clnstrt.dev/pypi/simple/"),
575 "resolved key must land in the URL userinfo; got:\n{blob}"
576 );
577 assert!(blob.contains("trusted-host = cleanapp.clnstrt.dev"));
578 assert!(
579 !blob.contains("${CLEANLIBRARY_API_KEY}"),
580 "pypi emit must never leave the placeholder in the URL — pip cannot expand it"
581 );
582 }
583
584 #[test]
585 fn pypi_emit_percent_encodes_key_with_reserved_characters() {
586 let blob = emit_pypi(&opts_with_key("https://cleanapp.clnstrt.dev", "k@e:y/1"))
590 .unwrap()
591 .config_blob;
592 assert!(
594 blob.contains("k%40e%3Ay%2F1@cleanapp.clnstrt.dev"),
595 "percent-encode reserved chars in userinfo; got:\n{blob}"
596 );
597 }
598
599 #[test]
600 fn pypi_emit_refuses_default_when_key_missing() {
601 let err = emit_pypi(&opts("https://cleanapp.clnstrt.dev")).unwrap_err();
606 assert!(matches!(err, ProxyConfigError::PypiRequiresResolvedKey));
607 }
608
609 #[test]
610 fn pypi_emit_refuses_default_on_whitespace_only_key() {
611 let mut o = opts_with_key("https://cleanapp.clnstrt.dev", " \t\n");
612 o.api_key = Some(" \t\n".to_string());
614 let err = emit_pypi(&o).unwrap_err();
615 assert!(matches!(err, ProxyConfigError::PypiRequiresResolvedKey));
616 }
617
618 #[test]
619 fn pypi_emit_netrc_branch_produces_credential_free_pip_conf() {
620 let mut o = opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc");
621 o.emit_netrc = true;
622 let cfg = emit_pypi(&o).unwrap();
623 let blob = &cfg.config_blob;
624 assert!(
626 blob.contains("index-url = https://cleanapp.clnstrt.dev/pypi/simple/"),
627 "pip.conf half must carry no credentials; got:\n{blob}"
628 );
629 assert!(
630 !blob.contains("@cleanapp.clnstrt.dev"),
631 "pip.conf URL must not carry an @-userinfo; got:\n{blob}"
632 );
633 assert!(
635 blob.contains("machine cleanapp.clnstrt.dev\n login cs_live_abc\n"),
636 ".netrc half must carry a machine block with the resolved key; got:\n{blob}"
637 );
638 assert!(
640 cfg.canonical_location.as_os_str().is_empty(),
641 "--emit-netrc must not silently write two files off one canonical target"
642 );
643 }
644
645 #[test]
646 fn pypi_emit_netrc_still_requires_a_key() {
647 let mut o = opts("https://cleanapp.clnstrt.dev");
650 o.emit_netrc = true;
651 let err = emit_pypi(&o).unwrap_err();
652 assert!(matches!(err, ProxyConfigError::PypiRequiresResolvedKey));
653 }
654
655 #[test]
656 fn pypi_emit_inline_token_true_and_default_produce_same_url_userinfo() {
657 let mut o_inline = opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc");
663 o_inline.inline_token = true;
664 let blob_inline = emit_pypi(&o_inline).unwrap().config_blob;
665 let blob_default =
666 emit_pypi(&opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc"))
667 .unwrap()
668 .config_blob;
669 assert_eq!(blob_inline, blob_default);
670 }
671
672 #[test]
673 fn go_emit_env_form() {
674 let blob = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
675 assert!(blob.contains("export GOPROXY=https://cleanapp.clnstrt.dev/go,direct"));
676 assert!(blob.contains("export GOAUTH=\"Authorization: Bearer ${CLEANLIBRARY_API_KEY}\""));
677 }
678
679 #[test]
680 fn go_emit_has_no_canonical_location() {
681 let cfg = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap();
682 assert!(cfg.canonical_location.as_os_str().is_empty());
683 }
684
685 #[test]
686 fn endpoint_trailing_slash_tolerated() {
687 let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev/")).unwrap().config_blob;
688 assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
690 assert!(!blob.contains("//npm/"));
691 }
692
693 #[test]
697 fn emit_rejects_inline_token_with_none_api_key() {
698 let mut o = opts("https://cleanapp.clnstrt.dev");
699 o.inline_token = true;
700 o.api_key = None;
701 let err = emit(Ecosystem::Npm, &o).unwrap_err();
702 assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Npm)));
703 }
704
705 #[test]
706 fn emit_rejects_inline_token_with_empty_string_api_key() {
707 let mut o = opts("https://cleanapp.clnstrt.dev");
708 o.inline_token = true;
709 o.api_key = Some(String::new());
710 let err = emit(Ecosystem::Pypi, &o).unwrap_err();
711 assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Pypi)));
712 }
713
714 #[test]
715 fn emit_rejects_inline_token_with_whitespace_only_api_key() {
716 let mut o = opts("https://cleanapp.clnstrt.dev");
717 o.inline_token = true;
718 o.api_key = Some(" \t\n".to_string());
719 let err = emit(Ecosystem::Go, &o).unwrap_err();
720 assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Go)));
721 }
722
723 #[test]
724 fn emit_accepts_inline_token_with_valid_api_key() {
725 let mut o = opts("https://cleanapp.clnstrt.dev");
726 o.inline_token = true;
727 o.api_key = Some("std_001".to_string());
728 let blob = emit(Ecosystem::Npm, &o).unwrap().config_blob;
729 assert!(blob.contains("_authToken=std_001"));
731 assert!(!blob.contains("_authToken=\n"));
733 assert!(!blob.contains("_authToken= "));
734 }
735
736 #[test]
737 fn emit_shell_expansion_path_unaffected_by_empty_key_for_env_expanding_ecosystems() {
738 let mut o = opts("https://cleanapp.clnstrt.dev");
750 o.inline_token = false;
751 o.api_key = None;
752 assert!(emit(Ecosystem::Npm, &o).is_ok());
753 assert!(emit(Ecosystem::Go, &o).is_ok());
754 assert!(emit(Ecosystem::Crates, &o).is_ok());
756 assert!(emit(Ecosystem::Maven, &o).is_ok());
757 }
759
760 #[test]
763 fn crates_emit_registry_url_and_placeholder_token() {
764 let blob = emit_crates(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
765 assert!(
767 blob.contains("index = \"sparse+https://cleanapp.clnstrt.dev/crates/\""),
768 "crates blob missing sparse index; got:\n{blob}"
769 );
770 assert!(
771 blob.contains("[registries.cleanlibrary]"),
772 "crates blob missing [registries.cleanlibrary]; got:\n{blob}"
773 );
774 assert!(
776 blob.contains("token = \"Bearer ${CLEANLIBRARY_API_KEY}\""),
777 "crates blob missing token placeholder; got:\n{blob}"
778 );
779 }
780
781 #[test]
782 fn crates_emit_inline_token_embeds_key() {
783 let mut o = opts("https://cleanapp.clnstrt.dev");
784 o.inline_token = true;
785 o.api_key = Some("cs_live_smoke".to_string());
786 let blob = emit_crates(&o).unwrap().config_blob;
787 assert!(
788 blob.contains("token = \"Bearer cs_live_smoke\""),
789 "inline_token must embed the key in the credentials.toml block; got:\n{blob}"
790 );
791 assert!(
795 !blob.contains("${CLEANLIBRARY_API_KEY}"),
796 "inline_token blob must NOT retain the placeholder"
797 );
798 }
799
800 #[test]
801 fn crates_emit_has_no_canonical_location() {
802 let cfg = emit_crates(&opts("https://cleanapp.clnstrt.dev")).unwrap();
806 assert!(cfg.canonical_location.as_os_str().is_empty());
807 }
808
809 #[test]
812 fn maven_emit_mirror_url_and_placeholder_token() {
813 let blob = emit_maven(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
814 assert!(
815 blob.contains("<url>https://cleanapp.clnstrt.dev/maven/</url>"),
816 "maven blob missing mirror URL; got:\n{blob}"
817 );
818 assert!(
819 blob.contains("<mirrorOf>*</mirrorOf>"),
820 "maven blob must divert every repo through the CleanLibrary mirror; got:\n{blob}"
821 );
822 assert!(
823 blob.contains("<value>Bearer ${CLEANLIBRARY_API_KEY}</value>"),
824 "maven blob must carry Bearer placeholder in the Authorization header; got:\n{blob}"
825 );
826 }
827
828 #[test]
829 fn maven_emit_inline_token_embeds_key() {
830 let mut o = opts("https://cleanapp.clnstrt.dev");
831 o.inline_token = true;
832 o.api_key = Some("cs_live_smoke".to_string());
833 let blob = emit_maven(&o).unwrap().config_blob;
834 assert!(
835 blob.contains("<value>Bearer cs_live_smoke</value>"),
836 "inline_token must embed the key in the httpHeaders block; got:\n{blob}"
837 );
838 assert!(
839 !blob.contains("${CLEANLIBRARY_API_KEY}"),
840 "inline_token blob must NOT retain the placeholder"
841 );
842 }
843
844 #[test]
845 fn maven_emit_has_no_canonical_location() {
846 let cfg = emit_maven(&opts("https://cleanapp.clnstrt.dev")).unwrap();
849 assert!(cfg.canonical_location.as_os_str().is_empty());
850 }
851
852 #[test]
853 fn crates_and_maven_endpoint_trailing_slash_tolerated() {
854 let crates_blob = emit_crates(&opts("https://cleanapp.clnstrt.dev/"))
858 .unwrap()
859 .config_blob;
860 assert!(crates_blob.contains("sparse+https://cleanapp.clnstrt.dev/crates/"));
861 assert!(!crates_blob.contains("//crates/"));
862
863 let maven_blob = emit_maven(&opts("https://cleanapp.clnstrt.dev/"))
864 .unwrap()
865 .config_blob;
866 assert!(maven_blob.contains("<url>https://cleanapp.clnstrt.dev/maven/</url>"));
867 assert!(!maven_blob.contains("//maven/"));
868 }
869
870 #[test]
873 fn nuget_parse_accepted_rubygems_composer_still_rejected() {
874 assert_eq!(Ecosystem::parse("nuget"), Some(Ecosystem::Nuget));
875 assert_eq!(Ecosystem::parse("rubygems"), None);
879 assert_eq!(Ecosystem::parse("composer"), None);
880 assert_eq!(Ecosystem::parse("Nuget"), None);
882 assert_eq!(Ecosystem::parse("NUGET"), None);
883 assert_eq!(Ecosystem::parse("nuspec"), None);
884 }
885
886 #[test]
887 fn supported_list_includes_nuget_not_rubygems_or_composer() {
888 let list = Ecosystem::supported_list();
889 assert!(
890 list.contains("nuget"),
891 "supported_list must advertise `nuget`; got: {}",
892 list
893 );
894 assert!(
895 !list.contains("rubygems") && !list.contains("composer"),
896 "supported_list must NOT advertise rubygems/composer — the \
897 platform does not resolve them yet; got: {}",
898 list
899 );
900 }
901
902 #[test]
903 fn nuget_emit_service_index_url_and_credentials_block() {
904 let blob = emit_nuget(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
905 assert!(
906 blob.contains("value=\"https://cleanapp.clnstrt.dev/nuget/v3/index.json\""),
907 "nuget blob missing the V3 service-index source entry; got:\n{blob}"
908 );
909 assert!(
910 blob.contains("<packageSourceCredentials>"),
911 "nuget blob missing the credentials block; got:\n{blob}"
912 );
913 assert!(
915 blob.contains("<add key=\"ClearTextPassword\" value=\"${CLEANLIBRARY_API_KEY}\" />"),
916 "nuget blob must carry the placeholder password by default; got:\n{blob}"
917 );
918 }
919
920 #[test]
921 fn nuget_emit_inline_token_embeds_key() {
922 let mut o = opts("https://cleanapp.clnstrt.dev");
923 o.inline_token = true;
924 o.api_key = Some("cs_live_smoke".to_string());
925 let blob = emit_nuget(&o).unwrap().config_blob;
926 assert!(
927 blob.contains("<add key=\"ClearTextPassword\" value=\"cs_live_smoke\" />"),
928 "inline_token must embed the key in the credentials block; got:\n{blob}"
929 );
930 assert!(
931 !blob.contains("${CLEANLIBRARY_API_KEY}"),
932 "inline_token blob must NOT retain the placeholder"
933 );
934 }
935
936 #[test]
937 fn nuget_emit_has_no_canonical_location() {
938 let cfg = emit_nuget(&opts("https://cleanapp.clnstrt.dev")).unwrap();
942 assert!(cfg.canonical_location.as_os_str().is_empty());
943 }
944
945 #[test]
946 fn nuget_emit_endpoint_trailing_slash_tolerated() {
947 let blob = emit_nuget(&opts("https://cleanapp.clnstrt.dev/"))
948 .unwrap()
949 .config_blob;
950 assert!(blob.contains("https://cleanapp.clnstrt.dev/nuget/v3/index.json"));
951 assert!(!blob.contains("//nuget/"));
952 }
953
954 #[test]
955 fn ecosystem_all_matches_what_the_platform_actually_resolves() {
956 const PLATFORM_RESOLVES: &[&str] =
969 &["npm", "pypi", "go", "crates", "maven", "nuget"];
970 let client_accepts: Vec<&str> = Ecosystem::ALL.iter().map(|e| e.as_str()).collect();
971 assert_eq!(
972 client_accepts, PLATFORM_RESOLVES,
973 "Ecosystem::ALL must track exactly what the platform actually \
974 resolves end-to-end, in the same order — update BOTH this \
975 constant and Ecosystem::ALL together, never one alone"
976 );
977 }
978}