1#[derive(Debug, Clone, PartialEq)]
13pub enum AuthPatternMatch {
14 OAuthUrl(String),
16 DeviceCode {
18 code: String,
20 verification_url: String,
22 },
23 LoginSuccess { email: Option<String> },
26 LoginFailure { message: String },
28}
29
30pub fn match_line(provider_id: &str, line: &str) -> Option<AuthPatternMatch> {
34 for matcher in patterns_for(provider_id) {
35 if let Some(m) = matcher(line) {
36 return Some(m);
37 }
38 }
39 if is_api_key_provider(provider_id) {
46 return None;
47 }
48 if let Some(url) = extract_first_https_url(line) {
49 if looks_like_oauth_url(&url) {
50 return Some(AuthPatternMatch::OAuthUrl(url));
51 }
52 }
53 None
54}
55
56fn is_api_key_provider(provider_id: &str) -> bool {
57 matches!(provider_id, "kimi" | "pi")
63}
64
65type LineMatcher = fn(&str) -> Option<AuthPatternMatch>;
66
67fn patterns_for(provider_id: &str) -> &'static [LineMatcher] {
68 match provider_id {
69 "claude" => &[match_claude_url, match_logged_in_as],
70 "codex" => &[match_codex_url, match_logged_in_as],
71 "openclaw" => &[match_codex_url, match_logged_in_as],
76 "gemini" => &[match_gemini_url, match_logged_in_as],
77 "copilot" => &[match_copilot_device_code, match_logged_in_as],
78 "kimi" | "pi" => &[],
82 _ => &[],
83 }
84}
85
86fn match_claude_url(line: &str) -> Option<AuthPatternMatch> {
91 if let Some(url) = extract_first_https_url(line) {
95 if url.contains("anthropic.com/oauth") || url.contains("console.anthropic.com") {
96 return Some(AuthPatternMatch::OAuthUrl(url));
97 }
98 }
99 None
100}
101
102fn match_codex_url(line: &str) -> Option<AuthPatternMatch> {
103 if let Some(url) = extract_first_https_url(line) {
104 if url.contains("auth.openai.com")
105 || url.contains("platform.openai.com")
106 || url.contains("openai.com/oauth")
107 {
108 return Some(AuthPatternMatch::OAuthUrl(url));
109 }
110 }
111 None
112}
113
114fn match_gemini_url(line: &str) -> Option<AuthPatternMatch> {
115 if let Some(url) = extract_first_https_url(line) {
116 if url.contains("accounts.google.com") || url.contains("oauth2.googleapis.com") {
117 return Some(AuthPatternMatch::OAuthUrl(url));
118 }
119 }
120 None
121}
122
123fn match_copilot_device_code(line: &str) -> Option<AuthPatternMatch> {
124 let line_low = line.to_lowercase();
130 let mentions_device = line_low.contains("github.com/login/device")
131 || line_low.contains("one-time code")
132 || line_low.contains("enter code");
133 if !mentions_device {
134 return None;
135 }
136 let code = extract_device_code(line);
137 if let Some(code) = code {
138 return Some(AuthPatternMatch::DeviceCode {
140 code,
141 verification_url: "https://github.com/login/device".to_string(),
142 });
143 }
144 None
145}
146
147fn match_logged_in_as(line: &str) -> Option<AuthPatternMatch> {
148 let line_low = line.to_lowercase();
149 if line_low.contains("not authenticated")
154 || line_low.contains("not logged in")
155 || line_low.contains("n't authenticated")
156 || line_low.contains("n't logged in")
157 || line_low.contains("failed to authenticate")
158 || line_low.contains("authentication failed")
159 {
160 return None;
161 }
162 if !line_low.contains("logged in") && !line_low.contains("authenticated") {
163 return None;
164 }
165 let email = extract_email(line);
168 Some(AuthPatternMatch::LoginSuccess { email })
169}
170
171fn extract_first_https_url(line: &str) -> Option<String> {
176 let start = line.find("https://")?;
177 let tail = &line[start..];
178 let end = tail
181 .find(|c: char| c.is_whitespace() || c == '\'' || c == '"' || c == '`' || c == ')')
182 .unwrap_or(tail.len());
183 let url = &tail[..end];
184 let trimmed = url.trim_end_matches(|c: char| matches!(c, '.' | ',' | ';' | '!' | '?'));
190 if trimmed.is_empty() {
191 None
192 } else {
193 Some(trimmed.to_string())
194 }
195}
196
197fn looks_like_oauth_url(url: &str) -> bool {
198 let u = url.to_lowercase();
199 u.contains("oauth")
200 || u.contains("/authorize")
201 || u.contains("/login")
202 || u.contains("/auth")
203 || u.contains("device")
204}
205
206fn extract_email(line: &str) -> Option<String> {
207 for token in line.split_whitespace() {
210 let token = token.trim_matches(|c: char| !c.is_alphanumeric() && c != '@' && c != '.' && c != '-' && c != '_' && c != '+');
211 if !token.contains('@') {
212 continue;
213 }
214 let Some((local, domain)) = token.split_once('@') else {
221 continue;
222 };
223 if local.is_empty() || domain.is_empty() {
224 continue;
225 }
226 if !domain.contains('.') {
227 continue;
228 }
229 return Some(token.to_string());
230 }
231 None
232}
233
234fn extract_device_code(line: &str) -> Option<String> {
235 for token in line.split(|c: char| c.is_whitespace() || c == ':' || c == ',' || c == '.') {
238 if token.len() == 9 {
239 let bytes = token.as_bytes();
240 let is_code = bytes[4] == b'-'
241 && bytes[..4]
242 .iter()
243 .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
244 && bytes[5..]
245 .iter()
246 .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit());
247 if is_code {
248 return Some(token.to_string());
249 }
250 }
251 }
252 None
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn claude_url_matched() {
261 let line = "Open this URL in your browser to authorize: https://console.anthropic.com/oauth/authorize?response_type=code&state=xyz";
262 let m = match_line("claude", line);
263 assert!(matches!(m, Some(AuthPatternMatch::OAuthUrl(_))));
264 if let Some(AuthPatternMatch::OAuthUrl(u)) = m {
265 assert!(u.contains("anthropic.com/oauth"));
266 assert!(u.contains("response_type=code"));
267 }
268 }
269
270 #[test]
271 fn codex_url_matched() {
272 let line = "Please visit https://auth.openai.com/u/login/identifier?state=abc to continue";
273 let m = match_line("codex", line);
274 assert!(matches!(m, Some(AuthPatternMatch::OAuthUrl(u)) if u.contains("openai.com")));
275 }
276
277 #[test]
278 fn gemini_url_matched() {
279 let line = "Visit https://accounts.google.com/o/oauth2/auth?client_id=abc&scope=...";
280 let m = match_line("gemini", line);
281 assert!(matches!(m, Some(AuthPatternMatch::OAuthUrl(u)) if u.contains("google.com")));
282 }
283
284 #[test]
285 fn copilot_device_code_matched() {
286 let line = "! First copy your one-time code: ABCD-1234";
287 let m = match_line("copilot", line);
288 if let Some(AuthPatternMatch::DeviceCode { code, verification_url }) = m {
289 assert_eq!(code, "ABCD-1234");
290 assert_eq!(verification_url, "https://github.com/login/device");
291 } else {
292 panic!("expected DeviceCode, got {m:?}");
293 }
294 }
295
296 #[test]
297 fn copilot_device_url_line_matched() {
298 let line = "Please visit https://github.com/login/device and enter code WXYZ-5678";
299 let m = match_line("copilot", line);
300 if let Some(AuthPatternMatch::DeviceCode { code, .. }) = m {
301 assert_eq!(code, "WXYZ-5678");
302 } else {
303 panic!("expected DeviceCode, got {m:?}");
304 }
305 }
306
307 #[test]
308 fn login_success_with_email() {
309 let m = match_line("claude", "Successfully logged in as asaf@example.com");
310 if let Some(AuthPatternMatch::LoginSuccess { email }) = m {
311 assert_eq!(email.as_deref(), Some("asaf@example.com"));
312 } else {
313 panic!("expected LoginSuccess, got {m:?}");
314 }
315 }
316
317 #[test]
318 fn login_success_without_email() {
319 let m = match_line("claude", "You are now authenticated.");
320 assert!(matches!(m, Some(AuthPatternMatch::LoginSuccess { email: None })));
321 }
322
323 #[test]
324 fn login_success_skips_negative_forms() {
325 for line in [
329 "Error: not authenticated",
330 "you are not authenticated",
331 "user isn't authenticated yet",
332 "You aren't logged in",
333 "failed to authenticate",
334 "Authentication failed",
335 ] {
336 let m = match_line("claude", line);
337 assert!(m.is_none(), "expected None for {line:?}, got {m:?}");
338 }
339 }
340
341 #[test]
342 fn fallback_https_matches_for_unknown_providers() {
343 let line = "Open https://example.com/oauth/authorize?state=x";
347 let m = match_line("future-provider", line);
348 assert!(matches!(m, Some(AuthPatternMatch::OAuthUrl(_))));
349 }
350
351 #[test]
352 fn fallback_skips_non_oauth_https() {
353 let line = "See https://docs.example.com/getting-started for details.";
356 let m = match_line("future-provider", line);
357 assert!(m.is_none());
358 }
359
360 #[test]
361 fn api_key_providers_match_nothing() {
362 for provider in ["kimi", "pi"] {
366 let m = match_line(provider, "Get your API key at https://example.com/keys");
367 assert!(m.is_none(), "provider {provider} unexpectedly matched");
368 }
369 }
370
371 #[test]
372 fn openclaw_matches_openai_oauth_url() {
373 let line = "Open this URL to sign in: https://auth.openai.com/oauth/authorize?...";
377 let m = match_line("openclaw", line);
378 assert!(matches!(m, Some(AuthPatternMatch::OAuthUrl(_))));
379 }
380
381 #[test]
382 fn url_extraction_trims_trailing_punctuation() {
383 let cases = [
388 ("Go to https://example.com/login.", "https://example.com/login"),
389 ("Open https://example.com/auth, then press Enter", "https://example.com/auth"),
390 ("Visit https://example.com/login!", "https://example.com/login"),
391 ("Done at https://example.com/oauth?state=xyz?", "https://example.com/oauth?state=xyz"),
392 ];
393 for (line, expected) in cases {
394 let url = extract_first_https_url(line).expect("url");
395 assert_eq!(url, expected, "for line: {line}");
396 }
397 }
398
399 #[test]
400 fn url_extraction_preserves_internal_punctuation() {
401 let line = "Open https://example.com/path.html?key=value&other=v2";
404 let url = extract_first_https_url(line).expect("url");
405 assert_eq!(url, "https://example.com/path.html?key=value&other=v2");
406 }
407
408 #[test]
409 fn api_key_provider_fallback_returns_none() {
410 for provider in ["kimi", "pi"] {
418 let line = "Get your API key at https://example.com/auth/keys";
419 assert!(match_line(provider, line).is_none(), "{provider} matched");
420 }
421 let m = match_line("unknown-provider", "Open https://example.com/oauth/authorize");
423 assert!(matches!(m, Some(AuthPatternMatch::OAuthUrl(_))));
424 }
425
426 #[test]
427 fn email_extractor_ignores_placeholders() {
428 assert!(extract_email("Sign in as <email>").is_none());
430 assert!(extract_email("Twitter handle: @example").is_none());
432 }
433
434 #[test]
435 fn device_code_extractor_recognises_correct_shape() {
436 assert_eq!(extract_device_code("code: ABCD-1234"), Some("ABCD-1234".to_string()));
437 assert_eq!(extract_device_code("the code is XYZW-0987 right here"), Some("XYZW-0987".to_string()));
438 assert_eq!(extract_device_code("code: ABC-1234"), None);
440 assert_eq!(extract_device_code("code: ABCD_1234"), None);
441 assert_eq!(extract_device_code("code: abcd-1234"), None);
443 }
444}