1use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::Path;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub struct DiscoveredBenchmark {
14 pub name: String,
16 pub framework: String,
18 pub command: String,
20 pub path: String,
22 pub language: String,
24 pub confidence: String,
26}
27
28pub fn discover_all(root: &Path) -> Vec<DiscoveredBenchmark> {
30 let mut results = Vec::new();
31 results.extend(scan_rust_criterion(root));
32 results.extend(scan_go_benchmarks(root));
33 results.extend(scan_python_pytest_benchmark(root));
34 results.extend(scan_javascript_benchmark(root));
35 results.extend(scan_custom_directories(root));
36 results.sort_by(|a, b| a.name.cmp(&b.name));
37 results
38}
39
40fn scan_rust_criterion(root: &Path) -> Vec<DiscoveredBenchmark> {
47 let mut results = Vec::new();
48
49 let cargo_toml = root.join("Cargo.toml");
51 if cargo_toml.is_file()
52 && let Ok(content) = fs::read_to_string(&cargo_toml)
53 {
54 results.extend(parse_cargo_bench_targets(&content, root));
55 }
56
57 let benches_dir = root.join("benches");
59 if benches_dir.is_dir() {
60 results.extend(scan_dir_for_criterion(&benches_dir, root));
61 }
62
63 let mut seen = std::collections::HashSet::new();
65 results.retain(|b| seen.insert(b.name.clone()));
66 results
67}
68
69fn parse_cargo_bench_targets(content: &str, root: &Path) -> Vec<DiscoveredBenchmark> {
71 let mut results = Vec::new();
72
73 let lines: Vec<&str> = content.lines().collect();
76 let mut i = 0;
77 while i < lines.len() {
78 let trimmed = lines[i].trim();
79 if trimmed == "[[bench]]" {
80 let mut name = None;
81 let mut harness = true;
82 let mut j = i + 1;
83 while j < lines.len() {
84 let ltrimmed = lines[j].trim();
85 if ltrimmed.starts_with('[') {
86 break;
87 }
88 if let Some(val) = extract_toml_string_value(ltrimmed, "name") {
89 name = Some(val);
90 }
91 if ltrimmed.starts_with("harness") && ltrimmed.contains("false") {
92 harness = false;
93 }
94 j += 1;
95 }
96 if let Some(bench_name) = name {
97 let confidence = if harness { "medium" } else { "high" };
98 let framework = if harness { "rust-bench" } else { "criterion" };
99 let command = format!("cargo bench --bench {bench_name}");
100
101 let bench_path = root.join("benches").join(format!("{bench_name}.rs"));
103 let rel_path = if bench_path.exists() {
104 format!("benches/{bench_name}.rs")
105 } else {
106 "Cargo.toml".to_string()
107 };
108
109 results.push(DiscoveredBenchmark {
110 name: bench_name,
111 framework: framework.to_string(),
112 command,
113 path: rel_path,
114 language: "rust".to_string(),
115 confidence: confidence.to_string(),
116 });
117 }
118 i = j;
119 } else {
120 i += 1;
121 }
122 }
123 results
124}
125
126fn extract_toml_string_value(line: &str, key: &str) -> Option<String> {
128 let trimmed = line.trim();
129 if !trimmed.starts_with(key) {
130 return None;
131 }
132 let rest = trimmed[key.len()..].trim();
133 let rest = rest.strip_prefix('=')?;
134 let rest = rest.trim();
135 let rest = rest.strip_prefix('"')?;
136 let rest = rest.strip_suffix('"')?;
137 Some(rest.to_string())
138}
139
140fn scan_dir_for_criterion(dir: &Path, root: &Path) -> Vec<DiscoveredBenchmark> {
142 let mut results = Vec::new();
143 let entries = match fs::read_dir(dir) {
144 Ok(e) => e,
145 Err(_) => return results,
146 };
147 for entry in entries.flatten() {
148 let path = entry.path();
149 if path.is_dir() {
150 results.extend(scan_dir_for_criterion(&path, root));
151 } else if path.extension().is_some_and(|ext| ext == "rs")
152 && let Ok(content) = fs::read_to_string(&path)
153 && (content.contains("criterion_group!") || content.contains("criterion_main!"))
154 {
155 let rel_path = path
156 .strip_prefix(root)
157 .unwrap_or(&path)
158 .to_string_lossy()
159 .replace('\\', "/");
160 let bench_name = path
161 .file_stem()
162 .map(|s| s.to_string_lossy().to_string())
163 .unwrap_or_else(|| "unknown".to_string());
164 results.push(DiscoveredBenchmark {
165 name: bench_name.clone(),
166 framework: "criterion".to_string(),
167 command: format!("cargo bench --bench {bench_name}"),
168 path: rel_path,
169 language: "rust".to_string(),
170 confidence: "high".to_string(),
171 });
172 }
173 }
174 results
175}
176
177fn scan_go_benchmarks(root: &Path) -> Vec<DiscoveredBenchmark> {
183 let mut results = Vec::new();
184 walk_files(root, &mut |path| {
185 let name = path.file_name().unwrap_or_default().to_string_lossy();
186 if name.ends_with("_test.go")
187 && let Ok(content) = fs::read_to_string(path)
188 {
189 for line in content.lines() {
190 let trimmed = line.trim();
191 if let Some(rest) = trimmed.strip_prefix("func Benchmark")
192 && let Some(paren_pos) = rest.find('(')
193 {
194 let func_name = rest[..paren_pos].trim();
195 if !func_name.is_empty()
196 && func_name.chars().next().is_some_and(|c| c.is_uppercase())
197 {
198 let full_name = format!("Benchmark{func_name}");
199 let rel_path = path
200 .strip_prefix(root)
201 .unwrap_or(path)
202 .to_string_lossy()
203 .replace('\\', "/");
204 let pkg_dir = path
205 .parent()
206 .and_then(|p| p.strip_prefix(root).ok())
207 .map(|p| p.to_string_lossy().replace('\\', "/"))
208 .unwrap_or_else(|| ".".to_string());
209 results.push(DiscoveredBenchmark {
210 name: full_name.clone(),
211 framework: "go-bench".to_string(),
212 command: format!("go test -bench=^{full_name}$ -benchmem ./{pkg_dir}"),
213 path: rel_path,
214 language: "go".to_string(),
215 confidence: "high".to_string(),
216 });
217 }
218 }
219 }
220 }
221 });
222 results
223}
224
225fn scan_python_pytest_benchmark(root: &Path) -> Vec<DiscoveredBenchmark> {
231 let mut results = Vec::new();
232 walk_files(root, &mut |path| {
233 let name = path.file_name().unwrap_or_default().to_string_lossy();
234 if (name.starts_with("test_") || name.ends_with("_test.py") || name.starts_with("bench_"))
235 && name.ends_with(".py")
236 && let Ok(content) = fs::read_to_string(path)
237 && content.contains("benchmark")
238 && content.contains("def ")
239 {
240 for line in content.lines() {
242 let trimmed = line.trim();
243 if let Some(rest) = trimmed.strip_prefix("def ")
244 && let Some(paren_pos) = rest.find('(')
245 && rest[paren_pos..].contains("benchmark")
246 {
247 let func_name = &rest[..paren_pos];
248 let rel_path = path
249 .strip_prefix(root)
250 .unwrap_or(path)
251 .to_string_lossy()
252 .replace('\\', "/");
253 results.push(DiscoveredBenchmark {
254 name: func_name.to_string(),
255 framework: "pytest-benchmark".to_string(),
256 command: format!("pytest --benchmark-only {rel_path}::{func_name}"),
257 path: rel_path,
258 language: "python".to_string(),
259 confidence: "high".to_string(),
260 });
261 }
262 }
263 }
264 });
265 results
266}
267
268fn scan_javascript_benchmark(root: &Path) -> Vec<DiscoveredBenchmark> {
274 let mut results = Vec::new();
275 walk_files(root, &mut |path| {
276 let name = path.file_name().unwrap_or_default().to_string_lossy();
277 if (name.ends_with(".js") || name.ends_with(".mjs"))
278 && (name.contains("bench") || name.contains("perf"))
279 && let Ok(content) = fs::read_to_string(path)
280 && (content.contains("suite.add") || content.contains("Suite"))
281 {
282 let rel_path = path
283 .strip_prefix(root)
284 .unwrap_or(path)
285 .to_string_lossy()
286 .replace('\\', "/");
287 let bench_name = path
288 .file_stem()
289 .map(|s| s.to_string_lossy().to_string())
290 .unwrap_or_else(|| "unknown".to_string());
291 results.push(DiscoveredBenchmark {
292 name: bench_name,
293 framework: "benchmark.js".to_string(),
294 command: format!("node {rel_path}"),
295 path: rel_path,
296 language: "javascript".to_string(),
297 confidence: "medium".to_string(),
298 });
299 }
300 });
301 results
302}
303
304fn scan_custom_directories(root: &Path) -> Vec<DiscoveredBenchmark> {
310 let mut results = Vec::new();
311 let dirs = ["benchmarks", "bench", "perf"];
312 for dir_name in &dirs {
313 let dir = root.join(dir_name);
314 if dir.is_dir() {
315 let entries = match fs::read_dir(&dir) {
316 Ok(e) => e,
317 Err(_) => continue,
318 };
319 for entry in entries.flatten() {
320 let path = entry.path();
321 if path.is_file() && is_likely_executable(&path) {
322 let rel_path = path
323 .strip_prefix(root)
324 .unwrap_or(&path)
325 .to_string_lossy()
326 .replace('\\', "/");
327 let bench_name = path
328 .file_stem()
329 .map(|s| s.to_string_lossy().to_string())
330 .unwrap_or_else(|| "unknown".to_string());
331 results.push(DiscoveredBenchmark {
332 name: bench_name,
333 framework: "custom".to_string(),
334 command: format!("./{rel_path}"),
335 path: rel_path,
336 language: "unknown".to_string(),
337 confidence: "low".to_string(),
338 });
339 }
340 }
341 }
342 }
343 results
344}
345
346fn is_likely_executable(path: &Path) -> bool {
350 if let Some(ext) = path.extension() {
352 let ext = ext.to_string_lossy().to_lowercase();
353 if matches!(
354 ext.as_str(),
355 "sh" | "bash" | "zsh" | "py" | "rb" | "pl" | "exe" | "bat" | "cmd" | "ps1"
356 ) {
357 return true;
358 }
359 }
360
361 if let Ok(content) = fs::read(path)
363 && content.starts_with(b"#!")
364 {
365 return true;
366 }
367
368 path.extension().is_none()
370}
371
372fn walk_files(root: &Path, callback: &mut dyn FnMut(&Path)) {
378 walk_files_inner(root, root, callback);
379}
380
381fn walk_files_inner(dir: &Path, root: &Path, callback: &mut dyn FnMut(&Path)) {
382 let entries = match fs::read_dir(dir) {
383 Ok(e) => e,
384 Err(_) => return,
385 };
386 for entry in entries.flatten() {
387 let path = entry.path();
388 if path.is_dir() {
389 let name = path.file_name().unwrap_or_default().to_string_lossy();
390 if matches!(
392 name.as_ref(),
393 "target"
394 | "node_modules"
395 | ".git"
396 | ".hg"
397 | ".svn"
398 | "__pycache__"
399 | "vendor"
400 | "dist"
401 | "build"
402 | ".tox"
403 | ".venv"
404 | "venv"
405 ) {
406 continue;
407 }
408 let depth = path
410 .strip_prefix(root)
411 .map(|p| p.components().count())
412 .unwrap_or(0);
413 if depth < 5 {
414 walk_files_inner(&path, root, callback);
415 }
416 } else {
417 callback(&path);
418 }
419 }
420}
421
422#[cfg(test)]
427mod tests {
428 use super::*;
429 use std::fs;
430
431 fn setup_temp_dir() -> tempfile::TempDir {
433 tempfile::tempdir().expect("failed to create temp dir")
434 }
435
436 #[test]
437 fn test_discover_empty_dir() {
438 let tmp = setup_temp_dir();
439 let results = discover_all(tmp.path());
440 assert!(results.is_empty());
441 }
442
443 #[test]
444 fn test_extract_toml_string_value() {
445 assert_eq!(
446 extract_toml_string_value(r#"name = "my_bench""#, "name"),
447 Some("my_bench".to_string())
448 );
449 assert_eq!(
450 extract_toml_string_value(r#" name = "spaced" "#, "name"),
451 Some("spaced".to_string())
452 );
453 assert_eq!(
454 extract_toml_string_value(r#"harness = false"#, "name"),
455 None
456 );
457 assert_eq!(
458 extract_toml_string_value(r#"name = "has_underscore""#, "name"),
459 Some("has_underscore".to_string())
460 );
461 }
462
463 #[test]
464 fn test_parse_cargo_bench_targets() {
465 let content = r#"
466[package]
467name = "myproject"
468
469[[bench]]
470name = "my_bench"
471harness = false
472
473[[bench]]
474name = "another"
475"#;
476 let tmp = setup_temp_dir();
477 let results = parse_cargo_bench_targets(content, tmp.path());
478 assert_eq!(results.len(), 2);
479
480 assert_eq!(results[0].name, "my_bench");
481 assert_eq!(results[0].framework, "criterion");
482 assert_eq!(results[0].confidence, "high");
483
484 assert_eq!(results[1].name, "another");
485 assert_eq!(results[1].framework, "rust-bench");
486 assert_eq!(results[1].confidence, "medium");
487 }
488
489 #[test]
490 fn test_scan_criterion_file() {
491 let tmp = setup_temp_dir();
492 let benches_dir = tmp.path().join("benches");
493 fs::create_dir(&benches_dir).unwrap();
494 fs::write(
495 benches_dir.join("sort_bench.rs"),
496 r#"
497use criterion::{criterion_group, criterion_main, Criterion};
498
499fn bench_sort(c: &mut Criterion) {
500 c.bench_function("sort_1000", |b| {
501 b.iter(|| {
502 let mut v: Vec<i32> = (0..1000).rev().collect();
503 v.sort();
504 })
505 });
506}
507
508criterion_group!(benches, bench_sort);
509criterion_main!(benches);
510"#,
511 )
512 .unwrap();
513
514 let results = scan_rust_criterion(tmp.path());
515 assert_eq!(results.len(), 1);
516 assert_eq!(results[0].name, "sort_bench");
517 assert_eq!(results[0].framework, "criterion");
518 assert_eq!(results[0].language, "rust");
519 assert_eq!(results[0].confidence, "high");
520 assert!(
521 results[0]
522 .command
523 .contains("cargo bench --bench sort_bench")
524 );
525 }
526
527 #[test]
528 fn test_scan_go_benchmarks() {
529 let tmp = setup_temp_dir();
530 fs::write(
531 tmp.path().join("sort_test.go"),
532 r#"
533package main
534
535import "testing"
536
537func BenchmarkSort(b *testing.B) {
538 for i := 0; i < b.N; i++ {
539 // sort something
540 }
541}
542
543func BenchmarkSearch(b *testing.B) {
544 for i := 0; i < b.N; i++ {
545 // search something
546 }
547}
548
549func TestNotABenchmark(t *testing.T) {}
550"#,
551 )
552 .unwrap();
553
554 let results = scan_go_benchmarks(tmp.path());
555 assert_eq!(results.len(), 2);
556
557 let names: Vec<&str> = results.iter().map(|b| b.name.as_str()).collect();
558 assert!(names.contains(&"BenchmarkSort"));
559 assert!(names.contains(&"BenchmarkSearch"));
560
561 for b in &results {
562 assert_eq!(b.framework, "go-bench");
563 assert_eq!(b.language, "go");
564 assert_eq!(b.confidence, "high");
565 assert!(b.command.contains("go test -bench="));
566 }
567 }
568
569 #[test]
570 fn test_scan_python_pytest_benchmark() {
571 let tmp = setup_temp_dir();
572 fs::write(
573 tmp.path().join("test_perf.py"),
574 r#"
575def test_sort_speed(benchmark):
576 benchmark(sorted, list(range(1000, 0, -1)))
577
578def test_not_a_benchmark():
579 assert True
580"#,
581 )
582 .unwrap();
583
584 let results = scan_python_pytest_benchmark(tmp.path());
585 assert_eq!(results.len(), 1);
586 assert_eq!(results[0].name, "test_sort_speed");
587 assert_eq!(results[0].framework, "pytest-benchmark");
588 assert_eq!(results[0].language, "python");
589 assert_eq!(results[0].confidence, "high");
590 }
591
592 #[test]
593 fn test_scan_javascript_benchmark() {
594 let tmp = setup_temp_dir();
595 fs::write(
596 tmp.path().join("bench.js"),
597 r#"
598const Benchmark = require('benchmark');
599const suite = new Benchmark.Suite;
600
601suite.add('sort', function() {
602 [3,1,2].sort();
603})
604.run();
605"#,
606 )
607 .unwrap();
608
609 let results = scan_javascript_benchmark(tmp.path());
610 assert_eq!(results.len(), 1);
611 assert_eq!(results[0].name, "bench");
612 assert_eq!(results[0].framework, "benchmark.js");
613 assert_eq!(results[0].language, "javascript");
614 assert_eq!(results[0].confidence, "medium");
615 }
616
617 #[test]
618 fn test_scan_custom_directories() {
619 let tmp = setup_temp_dir();
620 let bench_dir = tmp.path().join("benchmarks");
621 fs::create_dir(&bench_dir).unwrap();
622 fs::write(bench_dir.join("run_perf.sh"), "#!/bin/bash\necho hello").unwrap();
623 fs::write(bench_dir.join("README.md"), "# Benchmarks").unwrap();
624
625 let results = scan_custom_directories(tmp.path());
626 assert_eq!(results.len(), 1);
627 assert_eq!(results[0].name, "run_perf");
628 assert_eq!(results[0].framework, "custom");
629 assert_eq!(results[0].confidence, "low");
630 }
631
632 #[test]
633 fn test_is_likely_executable() {
634 let tmp = setup_temp_dir();
635 let sh_file = tmp.path().join("run.sh");
636 fs::write(&sh_file, "#!/bin/bash\necho hello").unwrap();
637 assert!(is_likely_executable(&sh_file));
638
639 let py_file = tmp.path().join("bench.py");
640 fs::write(&py_file, "print('hello')").unwrap();
641 assert!(is_likely_executable(&py_file));
642
643 let md_file = tmp.path().join("README.md");
644 fs::write(&md_file, "# Hello").unwrap();
645 assert!(!is_likely_executable(&md_file));
646
647 let shebang_file = tmp.path().join("my_bench");
649 fs::write(&shebang_file, "#!/usr/bin/env python\nprint('bench')").unwrap();
650 assert!(is_likely_executable(&shebang_file));
651 }
652
653 #[test]
654 fn test_discover_all_mixed() {
655 let tmp = setup_temp_dir();
656
657 fs::write(
659 tmp.path().join("Cargo.toml"),
660 r#"
661[package]
662name = "test"
663
664[[bench]]
665name = "perf_test"
666harness = false
667"#,
668 )
669 .unwrap();
670
671 fs::write(
673 tmp.path().join("algo_test.go"),
674 r#"
675package algo
676
677import "testing"
678
679func BenchmarkAlgo(b *testing.B) {}
680"#,
681 )
682 .unwrap();
683
684 let results = discover_all(tmp.path());
685 assert!(results.len() >= 2);
686
687 let names: Vec<&str> = results.iter().map(|b| b.name.as_str()).collect();
688 assert!(names.contains(&"perf_test"));
689 assert!(names.contains(&"BenchmarkAlgo"));
690 }
691
692 #[test]
693 fn test_discover_all_sorted_by_name() {
694 let tmp = setup_temp_dir();
695 let bench_dir = tmp.path().join("benchmarks");
696 fs::create_dir(&bench_dir).unwrap();
697 fs::write(bench_dir.join("zebra.sh"), "#!/bin/bash").unwrap();
698 fs::write(bench_dir.join("alpha.sh"), "#!/bin/bash").unwrap();
699
700 let results = discover_all(tmp.path());
701 if results.len() >= 2 {
702 assert!(results[0].name <= results[1].name);
703 }
704 }
705
706 #[test]
707 fn test_cargo_bench_targets_dedup() {
708 let tmp = setup_temp_dir();
711 fs::write(
712 tmp.path().join("Cargo.toml"),
713 r#"
714[package]
715name = "test"
716
717[[bench]]
718name = "my_bench"
719harness = false
720"#,
721 )
722 .unwrap();
723
724 let benches_dir = tmp.path().join("benches");
725 fs::create_dir(&benches_dir).unwrap();
726 fs::write(
727 benches_dir.join("my_bench.rs"),
728 "criterion_group!(benches, f);\ncriterion_main!(benches);",
729 )
730 .unwrap();
731
732 let results = scan_rust_criterion(tmp.path());
733 let count = results.iter().filter(|b| b.name == "my_bench").count();
735 assert_eq!(count, 1);
736 }
737
738 #[test]
739 fn test_walk_files_skips_git_dir() {
740 let tmp = setup_temp_dir();
741 let git_dir = tmp.path().join(".git");
742 fs::create_dir(&git_dir).unwrap();
743 fs::write(git_dir.join("config"), "core").unwrap();
744
745 let mut visited = Vec::new();
746 walk_files(tmp.path(), &mut |path| {
747 visited.push(path.to_path_buf());
748 });
749 assert!(
750 visited.is_empty(),
751 "should not visit files inside .git directory"
752 );
753 }
754}