1use std::path::PathBuf;
9
10use thiserror::Error;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Ecosystem {
15 Npm,
16 Pypi,
17 Go,
18}
19
20impl Ecosystem {
21 pub fn parse(s: &str) -> Option<Self> {
22 match s {
23 "npm" => Some(Self::Npm),
24 "pypi" => Some(Self::Pypi),
25 "go" => Some(Self::Go),
26 _ => None,
27 }
28 }
29 pub fn as_str(self) -> &'static str {
30 match self {
31 Self::Npm => "npm",
32 Self::Pypi => "pypi",
33 Self::Go => "go",
34 }
35 }
36}
37
38#[derive(Debug)]
42pub struct ProxyConfig {
43 pub ecosystem: Ecosystem,
44 pub config_blob: String,
45 pub canonical_location: PathBuf,
46}
47
48#[derive(Debug, Error)]
49pub enum ProxyConfigError {
50 #[error("home directory not discoverable; cannot resolve canonical location for {0:?}")]
51 HomeDirUnavailable(Ecosystem),
52 #[error(
56 "inline_token requires a non-empty api_key (would emit broken auth header for {0:?})"
57 )]
58 InlineTokenEmpty(Ecosystem),
59}
60
61pub struct EmitOptions {
63 pub endpoint: String,
66 pub scope: Option<String>,
68 pub inline_token: bool,
72 pub api_key: Option<String>,
74}
75
76pub fn emit(ecosystem: Ecosystem, opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
77 if opts.inline_token {
82 let has_usable_key = opts
83 .api_key
84 .as_deref()
85 .map(|k| !k.trim().is_empty())
86 .unwrap_or(false);
87 if !has_usable_key {
88 return Err(ProxyConfigError::InlineTokenEmpty(ecosystem));
89 }
90 }
91 match ecosystem {
92 Ecosystem::Npm => emit_npm(opts),
93 Ecosystem::Pypi => emit_pypi(opts),
94 Ecosystem::Go => emit_go(opts),
95 }
96}
97
98fn token_expression(opts: &EmitOptions) -> String {
99 if opts.inline_token {
100 opts.api_key.clone().unwrap_or_default()
104 } else {
105 "${CLEANLIBRARY_API_KEY}".to_string()
106 }
107}
108
109fn endpoint_host(endpoint: &str) -> &str {
110 endpoint
111 .trim_end_matches('/')
112 .trim_start_matches("https://")
113 .trim_start_matches("http://")
114}
115
116fn emit_npm(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
117 let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Npm))?;
118 let endpoint = opts.endpoint.trim_end_matches('/');
119 let registry_url = format!("{}/npm/", endpoint);
120 let host = endpoint_host(endpoint);
121 let token = token_expression(opts);
122
123 let config_blob = match opts.scope.as_deref() {
124 Some(scope) => format!(
125 "{scope}:registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
126 ),
127 None => format!(
128 "registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
129 ),
130 };
131
132 Ok(ProxyConfig {
133 ecosystem: Ecosystem::Npm,
134 config_blob,
135 canonical_location: home.join(".npmrc"),
136 })
137}
138
139fn emit_pypi(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
140 let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Pypi))?;
141 let endpoint = opts.endpoint.trim_end_matches('/');
142 let host = endpoint_host(endpoint);
143 let token = token_expression(opts);
144
145 let config_blob = format!(
146 "[global]\nindex-url = https://{token}@{host}/pypi/simple/\nextra-index-url =\n\n[install]\ntrusted-host = {host}\n",
147 );
148
149 Ok(ProxyConfig {
150 ecosystem: Ecosystem::Pypi,
151 config_blob,
152 canonical_location: home.join(".config").join("pip").join("pip.conf"),
153 })
154}
155
156fn emit_go(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
157 let endpoint = opts.endpoint.trim_end_matches('/');
158 let token = token_expression(opts);
159
160 let config_blob = format!(
163 "# 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",
164 );
165
166 Ok(ProxyConfig {
167 ecosystem: Ecosystem::Go,
168 config_blob,
169 canonical_location: PathBuf::new(),
171 })
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 fn opts(endpoint: &str) -> EmitOptions {
179 EmitOptions {
180 endpoint: endpoint.to_string(),
181 scope: None,
182 inline_token: false,
183 api_key: None,
184 }
185 }
186
187 #[test]
188 fn ecosystem_parse_vocab_locked() {
189 assert_eq!(Ecosystem::parse("npm"), Some(Ecosystem::Npm));
190 assert_eq!(Ecosystem::parse("pypi"), Some(Ecosystem::Pypi));
191 assert_eq!(Ecosystem::parse("go"), Some(Ecosystem::Go));
192 assert_eq!(Ecosystem::parse("NPM"), None);
194 assert_eq!(Ecosystem::parse("PyPI"), None);
195 assert_eq!(Ecosystem::parse("pip"), None);
196 assert_eq!(Ecosystem::parse("golang"), None);
197 }
198
199 #[test]
200 fn npm_emit_shell_expansion_default() {
201 let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
202 assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
203 assert!(blob.contains("//cleanapp.clnstrt.dev/npm/:_authToken=${CLEANLIBRARY_API_KEY}"));
204 assert!(blob.contains("always-auth=true"));
205 }
206
207 #[test]
208 fn npm_emit_with_scope() {
209 let mut o = opts("https://cleanapp.clnstrt.dev");
210 o.scope = Some("@my-org".to_string());
211 let blob = emit_npm(&o).unwrap().config_blob;
212 assert!(blob.contains("@my-org:registry=https://cleanapp.clnstrt.dev/npm/"));
213 }
214
215 #[test]
216 fn npm_emit_inline_token() {
217 let mut o = opts("https://cleanapp.clnstrt.dev");
218 o.inline_token = true;
219 o.api_key = Some("cs_live_smoke".to_string());
220 let blob = emit_npm(&o).unwrap().config_blob;
221 assert!(blob.contains("_authToken=cs_live_smoke"));
222 assert!(!blob.contains("${CLEANLIBRARY_API_KEY}"));
223 }
224
225 #[test]
226 fn pypi_emit_index_url_with_token_in_url() {
227 let blob = emit_pypi(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
228 assert!(blob.contains("index-url = https://${CLEANLIBRARY_API_KEY}@cleanapp.clnstrt.dev/pypi/simple/"));
230 assert!(blob.contains("trusted-host = cleanapp.clnstrt.dev"));
231 }
232
233 #[test]
234 fn go_emit_env_form() {
235 let blob = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
236 assert!(blob.contains("export GOPROXY=https://cleanapp.clnstrt.dev/go,direct"));
237 assert!(blob.contains("export GOAUTH=\"Authorization: Bearer ${CLEANLIBRARY_API_KEY}\""));
238 }
239
240 #[test]
241 fn go_emit_has_no_canonical_location() {
242 let cfg = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap();
243 assert!(cfg.canonical_location.as_os_str().is_empty());
244 }
245
246 #[test]
247 fn endpoint_trailing_slash_tolerated() {
248 let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev/")).unwrap().config_blob;
249 assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
251 assert!(!blob.contains("//npm/"));
252 }
253
254 #[test]
258 fn emit_rejects_inline_token_with_none_api_key() {
259 let mut o = opts("https://cleanapp.clnstrt.dev");
260 o.inline_token = true;
261 o.api_key = None;
262 let err = emit(Ecosystem::Npm, &o).unwrap_err();
263 assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Npm)));
264 }
265
266 #[test]
267 fn emit_rejects_inline_token_with_empty_string_api_key() {
268 let mut o = opts("https://cleanapp.clnstrt.dev");
269 o.inline_token = true;
270 o.api_key = Some(String::new());
271 let err = emit(Ecosystem::Pypi, &o).unwrap_err();
272 assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Pypi)));
273 }
274
275 #[test]
276 fn emit_rejects_inline_token_with_whitespace_only_api_key() {
277 let mut o = opts("https://cleanapp.clnstrt.dev");
278 o.inline_token = true;
279 o.api_key = Some(" \t\n".to_string());
280 let err = emit(Ecosystem::Go, &o).unwrap_err();
281 assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Go)));
282 }
283
284 #[test]
285 fn emit_accepts_inline_token_with_valid_api_key() {
286 let mut o = opts("https://cleanapp.clnstrt.dev");
287 o.inline_token = true;
288 o.api_key = Some("std_001".to_string());
289 let blob = emit(Ecosystem::Npm, &o).unwrap().config_blob;
290 assert!(blob.contains("_authToken=std_001"));
292 assert!(!blob.contains("_authToken=\n"));
294 assert!(!blob.contains("_authToken= "));
295 }
296
297 #[test]
298 fn emit_shell_expansion_path_unaffected_by_empty_key() {
299 let mut o = opts("https://cleanapp.clnstrt.dev");
302 o.inline_token = false;
303 o.api_key = None;
304 assert!(emit(Ecosystem::Npm, &o).is_ok());
305 assert!(emit(Ecosystem::Pypi, &o).is_ok());
306 assert!(emit(Ecosystem::Go, &o).is_ok());
307 }
308}