1use regex::Regex;
8use std::collections::{HashMap, HashSet};
9use std::sync::LazyLock;
10
11pub static SCANNER_VERB_MAP: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
18 let mut m = HashMap::new();
19 m.insert("GET", "list");
20 m.insert("GET_ID", "get");
21 m.insert("POST", "create");
22 m.insert("PUT", "update");
23 m.insert("PATCH", "patch");
24 m.insert("DELETE", "delete");
25 m.insert("HEAD", "head");
26 m.insert("OPTIONS", "options");
27 m
28});
29
30static PATH_PARAM_RE: LazyLock<Regex> =
34 LazyLock::new(|| Regex::new(r"\{[^}]+\}|:[a-zA-Z_]\w*").expect("valid regex"));
35
36static PATH_PARAM_FULL_RE: LazyLock<Regex> =
38 LazyLock::new(|| Regex::new(r"^(?:\{[^}]+\}|:[a-zA-Z_]\w*)$").expect("valid regex"));
39
40static PATH_PARAM_NAMED_RE: LazyLock<Regex> = LazyLock::new(|| {
43 Regex::new(r"\{(?P<brace>[^}]+)\}|:(?P<colon>[a-zA-Z_]\w*)").expect("valid regex")
44});
45
46pub fn has_path_params(path: &str) -> bool {
61 PATH_PARAM_RE.is_match(path)
62}
63
64pub fn resolve_http_verb(method: &str, path_has_params: bool) -> String {
86 let method_upper = method.to_uppercase();
87 if method_upper == "GET" {
88 let key = if path_has_params { "GET_ID" } else { "GET" };
89 return SCANNER_VERB_MAP.get(key).copied().unwrap_or("").to_string();
90 }
91 SCANNER_VERB_MAP
92 .get(method_upper.as_str())
93 .copied()
94 .map(|s| s.to_string())
95 .unwrap_or_else(|| method.to_lowercase())
96}
97
98pub fn extract_path_param_names(path: &str) -> HashSet<String> {
149 let mut names: HashSet<String> = HashSet::new();
150 for caps in PATH_PARAM_NAMED_RE.captures_iter(path) {
151 if let Some(m) = caps.name("brace").or_else(|| caps.name("colon")) {
152 names.insert(m.as_str().to_string());
153 }
154 }
155 names
156}
157
158pub fn substitute_path_params<V: AsRef<str>>(path: &str, values: &HashMap<&str, V>) -> String {
175 let mut result = String::with_capacity(path.len());
176 let mut last = 0usize;
177 for caps in PATH_PARAM_NAMED_RE.captures_iter(path) {
178 let whole = caps.get(0).expect("full match present");
179 result.push_str(&path[last..whole.start()]);
180 let name = caps
181 .name("brace")
182 .or_else(|| caps.name("colon"))
183 .map(|m| m.as_str());
184 match name.and_then(|n| values.get(n)) {
185 Some(v) => result.push_str(v.as_ref()),
186 None => result.push_str(whole.as_str()),
187 }
188 last = whole.end();
189 }
190 result.push_str(&path[last..]);
191 result
192}
193
194pub fn generate_suggested_alias(path: &str, method: &str) -> String {
195 let trimmed = path.trim_matches('/');
196 let raw_segments: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
197 let segments: Vec<&str> = raw_segments
198 .iter()
199 .copied()
200 .filter(|s| !PATH_PARAM_FULL_RE.is_match(s))
201 .collect();
202 let is_single_resource = raw_segments
203 .last()
204 .map(|s| PATH_PARAM_FULL_RE.is_match(s))
205 .unwrap_or(false);
206 let verb = resolve_http_verb(method, is_single_resource);
207 let mut parts: Vec<String> = segments.iter().map(|s| s.to_string()).collect();
208 parts.push(verb);
209 parts.join(".")
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
219 fn test_has_path_params_empty_string() {
220 assert!(!has_path_params(""));
221 }
222
223 #[test]
224 fn test_has_path_params_root_path() {
225 assert!(!has_path_params("/"));
226 }
227
228 #[test]
229 fn test_has_path_params_static_path() {
230 assert!(!has_path_params("/tasks"));
231 }
232
233 #[test]
234 fn test_has_path_params_brace_style() {
235 assert!(has_path_params("/tasks/{id}"));
236 }
237
238 #[test]
239 fn test_has_path_params_colon_style() {
240 assert!(has_path_params("/tasks/:id"));
241 }
242
243 #[test]
244 fn test_has_path_params_mixed_styles() {
245 assert!(has_path_params("/{id}/:name"));
246 }
247
248 #[test]
249 fn test_has_path_params_multi_segment_static() {
250 assert!(!has_path_params("/a/b/c"));
251 }
252
253 #[test]
254 fn test_has_path_params_empty_brace() {
255 assert!(!has_path_params("/tasks/{}"));
256 }
257
258 #[test]
261 fn test_resolve_http_verb_get_collection() {
262 assert_eq!(resolve_http_verb("GET", false), "list");
263 }
264
265 #[test]
266 fn test_resolve_http_verb_get_single() {
267 assert_eq!(resolve_http_verb("GET", true), "get");
268 }
269
270 #[test]
271 fn test_resolve_http_verb_get_case_insensitive() {
272 assert_eq!(resolve_http_verb("get", false), "list");
273 }
274
275 #[test]
276 fn test_resolve_http_verb_post_no_params() {
277 assert_eq!(resolve_http_verb("POST", false), "create");
278 }
279
280 #[test]
281 fn test_resolve_http_verb_post_with_params() {
282 assert_eq!(resolve_http_verb("POST", true), "create");
283 }
284
285 #[test]
286 fn test_resolve_http_verb_put() {
287 assert_eq!(resolve_http_verb("PUT", true), "update");
288 }
289
290 #[test]
291 fn test_resolve_http_verb_patch() {
292 assert_eq!(resolve_http_verb("PATCH", true), "patch");
293 }
294
295 #[test]
296 fn test_resolve_http_verb_delete() {
297 assert_eq!(resolve_http_verb("DELETE", true), "delete");
298 }
299
300 #[test]
301 fn test_resolve_http_verb_head() {
302 assert_eq!(resolve_http_verb("HEAD", false), "head");
303 }
304
305 #[test]
306 fn test_resolve_http_verb_options() {
307 assert_eq!(resolve_http_verb("OPTIONS", false), "options");
308 }
309
310 #[test]
311 fn test_resolve_http_verb_unknown_method() {
312 assert_eq!(resolve_http_verb("PURGE", false), "purge");
313 }
314
315 #[test]
316 fn test_resolve_http_verb_empty_method() {
317 assert_eq!(resolve_http_verb("", false), "");
318 }
319
320 #[test]
323 fn test_generate_alias_post_collection() {
324 assert_eq!(
325 generate_suggested_alias("/tasks/user_data", "POST"),
326 "tasks.user_data.create"
327 );
328 }
329
330 #[test]
331 fn test_generate_alias_get_collection() {
332 assert_eq!(
333 generate_suggested_alias("/tasks/user_data", "GET"),
334 "tasks.user_data.list"
335 );
336 }
337
338 #[test]
339 fn test_generate_alias_get_single() {
340 assert_eq!(
341 generate_suggested_alias("/tasks/user_data/{id}", "GET"),
342 "tasks.user_data.get"
343 );
344 }
345
346 #[test]
347 fn test_generate_alias_put_single() {
348 assert_eq!(
349 generate_suggested_alias("/tasks/user_data/{id}", "PUT"),
350 "tasks.user_data.update"
351 );
352 }
353
354 #[test]
355 fn test_generate_alias_patch_single() {
356 assert_eq!(
357 generate_suggested_alias("/tasks/user_data/{id}", "PATCH"),
358 "tasks.user_data.patch"
359 );
360 }
361
362 #[test]
363 fn test_generate_alias_delete_single() {
364 assert_eq!(
365 generate_suggested_alias("/tasks/user_data/{id}", "DELETE"),
366 "tasks.user_data.delete"
367 );
368 }
369
370 #[test]
371 fn test_generate_alias_single_segment() {
372 assert_eq!(generate_suggested_alias("/health", "GET"), "health.list");
373 }
374
375 #[test]
376 fn test_generate_alias_root_path() {
377 assert_eq!(generate_suggested_alias("/", "GET"), "list");
378 }
379
380 #[test]
381 fn test_generate_alias_empty_path() {
382 assert_eq!(generate_suggested_alias("", "GET"), "list");
383 }
384
385 #[test]
386 fn test_generate_alias_colon_param() {
387 assert_eq!(
388 generate_suggested_alias("/users/:user_id", "GET"),
389 "users.get"
390 );
391 }
392
393 #[test]
394 fn test_generate_alias_version_prefix() {
395 assert_eq!(
396 generate_suggested_alias("/api/v2/users", "GET"),
397 "api.v2.users.list"
398 );
399 }
400
401 #[test]
402 fn test_generate_alias_nested_params_collection() {
403 assert_eq!(
404 generate_suggested_alias("/orgs/{org_id}/teams/{team_id}/members", "GET"),
405 "orgs.teams.members.list"
406 );
407 }
408
409 #[test]
410 fn test_generate_alias_double_slashes() {
411 assert_eq!(
412 generate_suggested_alias("//tasks//user_data//", "POST"),
413 "tasks.user_data.create"
414 );
415 }
416
417 #[test]
418 fn test_generate_alias_param_only_path() {
419 assert_eq!(generate_suggested_alias("/{id}", "GET"), "get");
420 }
421
422 #[test]
425 fn test_extract_names_static_path_empty() {
426 assert!(extract_path_param_names("/tasks").is_empty());
427 }
428
429 #[test]
430 fn test_extract_names_brace_single() {
431 let names = extract_path_param_names("/users/{id}");
432 assert_eq!(names.len(), 1);
433 assert!(names.contains("id"));
434 }
435
436 #[test]
437 fn test_extract_names_colon_single() {
438 let names = extract_path_param_names("/users/:id");
439 assert_eq!(names.len(), 1);
440 assert!(names.contains("id"));
441 }
442
443 #[test]
444 fn test_extract_names_mixed_multiple() {
445 let names = extract_path_param_names("/orgs/{org_id}/members/:member_id");
446 assert_eq!(names.len(), 2);
447 assert!(names.contains("org_id"));
448 assert!(names.contains("member_id"));
449 }
450
451 #[test]
452 fn test_extract_names_deduplicates() {
453 let names = extract_path_param_names("/a/{id}/b/{id}");
454 assert_eq!(names.len(), 1);
455 assert!(names.contains("id"));
456 }
457
458 #[test]
461 fn test_substitute_brace_value() {
462 let mut values: HashMap<&str, String> = HashMap::new();
463 values.insert("id", "42".to_string());
464 assert_eq!(substitute_path_params("/users/{id}", &values), "/users/42");
465 }
466
467 #[test]
468 fn test_substitute_colon_value() {
469 let mut values: HashMap<&str, String> = HashMap::new();
470 values.insert("id", "abc".to_string());
471 assert_eq!(substitute_path_params("/users/:id", &values), "/users/abc");
472 }
473
474 #[test]
475 fn test_substitute_leaves_unknown_placeholder() {
476 let mut values: HashMap<&str, String> = HashMap::new();
477 values.insert("id", "1".to_string());
478 assert_eq!(
479 substitute_path_params("/users/{id}/{role}", &values),
480 "/users/1/{role}"
481 );
482 }
483
484 #[test]
485 fn test_substitute_ignores_extra_keys() {
486 let mut values: HashMap<&str, String> = HashMap::new();
487 values.insert("id", "1".to_string());
488 values.insert("extra", "x".to_string());
489 assert_eq!(substitute_path_params("/users/{id}", &values), "/users/1");
490 }
491
492 #[test]
493 fn test_substitute_no_placeholders() {
494 let values: HashMap<&str, String> = HashMap::new();
495 assert_eq!(substitute_path_params("/tasks", &values), "/tasks");
496 }
497
498 #[test]
499 fn test_substitute_multiple_mixed_styles() {
500 let mut values: HashMap<&str, String> = HashMap::new();
501 values.insert("org_id", "7".to_string());
502 values.insert("m", "me".to_string());
503 assert_eq!(
504 substitute_path_params("/orgs/{org_id}/members/:m", &values),
505 "/orgs/7/members/me"
506 );
507 }
508
509 #[test]
512 fn test_scanner_verb_map_contains_standard_methods() {
513 for k in &[
514 "GET", "GET_ID", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS",
515 ] {
516 assert!(SCANNER_VERB_MAP.contains_key(k), "missing key: {}", k);
517 }
518 }
519
520 #[test]
521 fn test_scanner_verb_map_values_lowercase() {
522 for v in SCANNER_VERB_MAP.values() {
523 assert_eq!(*v, &*v.to_lowercase());
524 }
525 }
526
527 #[test]
530 fn test_conformance_fixture() {
531 let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
532 .join("tests")
533 .join("fixtures")
534 .join("scanner_verb_map.json");
535
536 let content = std::fs::read_to_string(&fixture_path)
537 .unwrap_or_else(|e| panic!("failed to read fixture at {:?}: {}", fixture_path, e));
538
539 let cases: serde_json::Value =
540 serde_json::from_str(&content).expect("fixture must be valid JSON");
541
542 let array = cases.as_array().expect("fixture must be a JSON array");
543 assert!(!array.is_empty(), "fixture must contain at least one case");
544
545 for case in array {
546 let path = case["path"].as_str().unwrap();
547 let method = case["method"].as_str().unwrap();
548 let expected = case["expected_alias"].as_str().unwrap();
549
550 let result = generate_suggested_alias(path, method);
551 assert_eq!(
552 result, expected,
553 "fixture mismatch for {} {}: got {}, expected {}",
554 method, path, result, expected
555 );
556 }
557 }
558}