Skip to main content

perfgate_app/
init.rs

1//! Benchmark discovery and config generation for `perfgate init`.
2//!
3//! Scans a repository to detect benchmark targets and generates
4//! a `perfgate.toml` configuration file.
5
6use perfgate_types::{BenchConfigFile, ConfigFile, DefaultsConfig};
7use std::fmt;
8use std::path::{Path, PathBuf};
9
10// ---------------------------------------------------------------------------
11// Public types
12// ---------------------------------------------------------------------------
13
14/// How a benchmark was discovered.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum BenchSource {
17    /// A `[[bench]]` target in `Cargo.toml`.
18    CargoTarget,
19    /// Detected via `criterion_group!` / `criterion_main!` macros.
20    Criterion,
21    /// Go `func Benchmark*` in `*_test.go`.
22    GoBench,
23    /// Python pytest-benchmark detected.
24    PytestBenchmark,
25    /// Fallback / user-supplied.
26    Custom,
27}
28
29impl fmt::Display for BenchSource {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            BenchSource::CargoTarget => write!(f, "cargo bench target"),
33            BenchSource::Criterion => write!(f, "criterion benchmark"),
34            BenchSource::GoBench => write!(f, "go benchmark"),
35            BenchSource::PytestBenchmark => write!(f, "pytest-benchmark"),
36            BenchSource::Custom => write!(f, "custom"),
37        }
38    }
39}
40
41/// A benchmark discovered by scanning the repository.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct DiscoveredBench {
44    pub name: String,
45    pub command: Vec<String>,
46    pub source: BenchSource,
47}
48
49/// Budget preset for config generation.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Preset {
52    Standard,
53    Release,
54    Tier1Fast,
55}
56
57impl Preset {
58    pub fn defaults(self) -> DefaultsConfig {
59        match self {
60            Preset::Standard => DefaultsConfig {
61                repeat: Some(5),
62                warmup: Some(1),
63                threshold: Some(0.20),
64                ..DefaultsConfig::default()
65            },
66            Preset::Release => DefaultsConfig {
67                repeat: Some(10),
68                warmup: Some(2),
69                threshold: Some(0.10),
70                ..DefaultsConfig::default()
71            },
72            Preset::Tier1Fast => DefaultsConfig {
73                repeat: Some(3),
74                warmup: Some(1),
75                threshold: Some(0.30),
76                ..DefaultsConfig::default()
77            },
78        }
79    }
80}
81
82/// CI platform for workflow scaffolding.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum CiPlatform {
85    GitHub,
86    GitLab,
87    Bitbucket,
88    CircleCi,
89}
90
91// ---------------------------------------------------------------------------
92// Benchmark discovery
93// ---------------------------------------------------------------------------
94
95/// Scan `root` for benchmarks.  Does not recurse into hidden or `target` dirs.
96pub fn discover_benchmarks(root: &Path) -> Vec<DiscoveredBench> {
97    let mut found: Vec<DiscoveredBench> = Vec::new();
98
99    discover_rust_benches(root, &mut found);
100    discover_go_benches(root, &mut found);
101    discover_python_benches(root, &mut found);
102
103    // De-duplicate by name (first wins).
104    let mut seen = std::collections::HashSet::new();
105    found.retain(|b| seen.insert(b.name.clone()));
106
107    found
108}
109
110// -- Rust / Cargo ----------------------------------------------------------
111
112fn discover_rust_benches(root: &Path, out: &mut Vec<DiscoveredBench>) {
113    let cargo_toml = root.join("Cargo.toml");
114    if !cargo_toml.is_file() {
115        return;
116    }
117
118    let content = match std::fs::read_to_string(&cargo_toml) {
119        Ok(c) => c,
120        Err(_) => return,
121    };
122
123    // Parse [[bench]] targets.
124    if let Ok(parsed) = content.parse::<toml::Table>()
125        && let Some(toml::Value::Array(benches)) = parsed.get("bench")
126    {
127        for bench in benches {
128            if let Some(name) = bench.get("name").and_then(|v| v.as_str()) {
129                let harness = bench
130                    .get("harness")
131                    .and_then(|v| v.as_bool())
132                    .unwrap_or(true);
133
134                let source = if harness {
135                    BenchSource::CargoTarget
136                } else {
137                    // harness = false usually means Criterion or custom runner
138                    BenchSource::Criterion
139                };
140
141                out.push(DiscoveredBench {
142                    name: name.to_string(),
143                    command: vec![
144                        "cargo".into(),
145                        "bench".into(),
146                        "--bench".into(),
147                        name.to_string(),
148                    ],
149                    source,
150                });
151            }
152        }
153    }
154
155    // Scan benches/ directory for criterion macros.
156    let benches_dir = root.join("benches");
157    if benches_dir.is_dir() {
158        scan_dir_for_criterion(&benches_dir, out);
159    }
160}
161
162fn scan_dir_for_criterion(dir: &Path, out: &mut Vec<DiscoveredBench>) {
163    let entries = match std::fs::read_dir(dir) {
164        Ok(e) => e,
165        Err(_) => return,
166    };
167
168    for entry in entries.flatten() {
169        let path = entry.path();
170        if path.extension().and_then(|e| e.to_str()) != Some("rs") {
171            continue;
172        }
173
174        let content = match std::fs::read_to_string(&path) {
175            Ok(c) => c,
176            Err(_) => continue,
177        };
178
179        if content.contains("criterion_group!") || content.contains("criterion_main!") {
180            let stem = path
181                .file_stem()
182                .and_then(|s| s.to_str())
183                .unwrap_or("benchmark");
184
185            // Only add if not already discovered via [[bench]].
186            if !out.iter().any(|b| b.name == stem) {
187                out.push(DiscoveredBench {
188                    name: stem.to_string(),
189                    command: vec![
190                        "cargo".into(),
191                        "bench".into(),
192                        "--bench".into(),
193                        stem.to_string(),
194                    ],
195                    source: BenchSource::Criterion,
196                });
197            }
198        }
199    }
200}
201
202// -- Go --------------------------------------------------------------------
203
204fn discover_go_benches(root: &Path, out: &mut Vec<DiscoveredBench>) {
205    // Look for go.mod first.
206    if !root.join("go.mod").is_file() {
207        return;
208    }
209
210    walk_for_go_bench_files(root, root, out);
211}
212
213fn walk_for_go_bench_files(root: &Path, dir: &Path, out: &mut Vec<DiscoveredBench>) {
214    let entries = match std::fs::read_dir(dir) {
215        Ok(e) => e,
216        Err(_) => return,
217    };
218
219    for entry in entries.flatten() {
220        let path = entry.path();
221
222        if path.is_dir() {
223            let name = path
224                .file_name()
225                .and_then(|n| n.to_str())
226                .unwrap_or_default();
227            if name.starts_with('.') || name == "vendor" || name == "node_modules" {
228                continue;
229            }
230            walk_for_go_bench_files(root, &path, out);
231            continue;
232        }
233
234        let file_name = path
235            .file_name()
236            .and_then(|n| n.to_str())
237            .unwrap_or_default();
238        if !file_name.ends_with("_test.go") {
239            continue;
240        }
241
242        let content = match std::fs::read_to_string(&path) {
243            Ok(c) => c,
244            Err(_) => continue,
245        };
246
247        if content.contains("func Benchmark") {
248            let pkg_dir = path.parent().unwrap_or(root);
249            let rel = pkg_dir
250                .strip_prefix(root)
251                .unwrap_or(pkg_dir)
252                .to_string_lossy()
253                .replace('\\', "/");
254
255            let pkg = if rel.is_empty() {
256                ".".to_string()
257            } else {
258                format!("./{rel}")
259            };
260
261            let bench_name = format!("go-bench-{}", rel.replace('/', "-")).replace("..", "root");
262            let bench_name = if bench_name == "go-bench-" {
263                "go-bench".to_string()
264            } else {
265                bench_name
266            };
267
268            if !out.iter().any(|b| b.name == bench_name) {
269                out.push(DiscoveredBench {
270                    name: bench_name,
271                    command: vec![
272                        "go".into(),
273                        "test".into(),
274                        "-bench=.".into(),
275                        "-benchmem".into(),
276                        pkg,
277                    ],
278                    source: BenchSource::GoBench,
279                });
280            }
281        }
282    }
283}
284
285// -- Python ----------------------------------------------------------------
286
287fn discover_python_benches(root: &Path, out: &mut Vec<DiscoveredBench>) {
288    let markers = [
289        "requirements.txt",
290        "requirements-dev.txt",
291        "requirements-test.txt",
292        "setup.py",
293        "setup.cfg",
294        "pyproject.toml",
295    ];
296
297    let mut has_pytest_benchmark = false;
298    for marker in &markers {
299        let path = root.join(marker);
300        if let Ok(content) = std::fs::read_to_string(&path)
301            && (content.contains("pytest-benchmark") || content.contains("pytest_benchmark"))
302        {
303            has_pytest_benchmark = true;
304            break;
305        }
306    }
307
308    // Also check for conftest.py with benchmark fixture usage.
309    if !has_pytest_benchmark {
310        let conftest = root.join("conftest.py");
311        if let Ok(content) = std::fs::read_to_string(&conftest)
312            && content.contains("benchmark")
313        {
314            has_pytest_benchmark = true;
315        }
316    }
317
318    if has_pytest_benchmark {
319        out.push(DiscoveredBench {
320            name: "pytest-bench".to_string(),
321            command: vec![
322                "pytest".into(),
323                "--benchmark-only".into(),
324                "--benchmark-json=benchmark.json".into(),
325            ],
326            source: BenchSource::PytestBenchmark,
327        });
328    }
329}
330
331// ---------------------------------------------------------------------------
332// Config generation
333// ---------------------------------------------------------------------------
334
335/// Build a `ConfigFile` from discovered benchmarks and a preset.
336pub fn generate_config(benchmarks: &[DiscoveredBench], preset: Preset) -> ConfigFile {
337    let defaults = preset.defaults();
338
339    let benches: Vec<BenchConfigFile> = benchmarks
340        .iter()
341        .map(|b| BenchConfigFile {
342            name: b.name.clone(),
343            command: b.command.clone(),
344            cwd: None,
345            work: None,
346            timeout: None,
347            repeat: None,
348            warmup: None,
349            metrics: None,
350            budgets: None,
351            scaling: None,
352        })
353        .collect();
354
355    ConfigFile {
356        defaults,
357        benches,
358        ..ConfigFile::default()
359    }
360}
361
362/// Render a `ConfigFile` to a well-commented TOML string.
363pub fn render_config_toml(config: &ConfigFile) -> String {
364    let mut out = String::new();
365
366    out.push_str("# perfgate.toml — generated by `perfgate init`\n");
367    out.push_str("#\n");
368    out.push_str("# Documentation: https://github.com/EffortlessMetrics/perfgate\n\n");
369
370    // [defaults]
371    out.push_str("# Default settings applied to all benchmarks unless overridden.\n");
372    out.push_str("[defaults]\n");
373    if let Some(repeat) = config.defaults.repeat {
374        out.push_str(&format!(
375            "# Number of measured samples per benchmark run.\n\
376             repeat = {repeat}\n"
377        ));
378    }
379    if let Some(warmup) = config.defaults.warmup {
380        out.push_str(&format!(
381            "# Warmup iterations excluded from statistics.\n\
382             warmup = {warmup}\n"
383        ));
384    }
385    if let Some(threshold) = config.defaults.threshold {
386        out.push_str(&format!(
387            "# Maximum allowed regression fraction (0.20 = 20%).\n\
388             threshold = {threshold:.2}\n"
389        ));
390    }
391    if let Some(ref out_dir) = config.defaults.out_dir {
392        out.push_str(&format!("out_dir = \"{out_dir}\"\n"));
393    }
394    if let Some(ref baseline_dir) = config.defaults.baseline_dir {
395        out.push_str(&format!("baseline_dir = \"{baseline_dir}\"\n"));
396    }
397
398    // [[bench]] entries
399    for bench in &config.benches {
400        out.push_str(&format!("\n[[bench]]\nname = \"{}\"\n", bench.name));
401
402        // Format command as TOML array.
403        let parts: Vec<String> = bench.command.iter().map(|c| format!("\"{c}\"")).collect();
404        out.push_str(&format!("command = [{}]\n", parts.join(", ")));
405
406        if let Some(ref cwd) = bench.cwd {
407            out.push_str(&format!("cwd = \"{cwd}\"\n"));
408        }
409        if let Some(repeat) = bench.repeat {
410            out.push_str(&format!("repeat = {repeat}\n"));
411        }
412        if let Some(warmup) = bench.warmup {
413            out.push_str(&format!("warmup = {warmup}\n"));
414        }
415        if let Some(ref timeout) = bench.timeout {
416            out.push_str(&format!("timeout = \"{timeout}\"\n"));
417        }
418    }
419
420    out
421}
422
423// ---------------------------------------------------------------------------
424// CI scaffold
425// ---------------------------------------------------------------------------
426
427/// Generate CI workflow content for the given platform.
428pub fn scaffold_ci(platform: CiPlatform, config_path: &Path) -> String {
429    let config_str = config_path.to_string_lossy().replace('\\', "/");
430    match platform {
431        CiPlatform::GitHub => scaffold_github(&config_str),
432        CiPlatform::GitLab => scaffold_gitlab(&config_str),
433        CiPlatform::Bitbucket => scaffold_bitbucket(&config_str),
434        CiPlatform::CircleCi => scaffold_circleci(&config_str),
435    }
436}
437
438fn scaffold_github(config_path: &str) -> String {
439    format!(
440        r#"# .github/workflows/perfgate.yml — generated by `perfgate init`
441name: Performance Gate
442
443on:
444  pull_request:
445    branches: [main]
446
447permissions:
448  pull-requests: write
449
450jobs:
451  bench:
452    runs-on: ubuntu-latest
453    steps:
454      - uses: actions/checkout@v4
455
456      - name: Install perfgate
457        run: cargo install perfgate-cli
458
459      - name: Run benchmarks
460        run: perfgate check --config {config_path} --all --mode cockpit --out-dir artifacts/perfgate
461
462      - name: Upload artifacts
463        uses: actions/upload-artifact@v4
464        with:
465          name: perfgate
466          path: artifacts/perfgate/
467
468      - name: Post PR comment
469        if: github.event_name == 'pull_request'
470        run: |
471          if [ -f artifacts/perfgate/comment.md ]; then
472            gh pr comment ${{{{ github.event.pull_request.number }}}} --body-file artifacts/perfgate/comment.md
473          fi
474        env:
475          GH_TOKEN: ${{{{ secrets.GITHUB_TOKEN }}}}
476"#
477    )
478}
479
480fn scaffold_gitlab(config_path: &str) -> String {
481    format!(
482        r#"# .gitlab-ci.yml snippet — generated by `perfgate init`
483perfgate:
484  stage: test
485  script:
486    - cargo install perfgate-cli
487    - perfgate check --config {config_path} --all --mode cockpit --out-dir artifacts/perfgate
488  artifacts:
489    paths:
490      - artifacts/perfgate/
491    when: always
492"#
493    )
494}
495
496fn scaffold_bitbucket(config_path: &str) -> String {
497    format!(
498        r#"# bitbucket-pipelines.yml snippet — generated by `perfgate init`
499pipelines:
500  pull-requests:
501    '**':
502      - step:
503          name: Performance Gate
504          script:
505            - cargo install perfgate-cli
506            - perfgate check --config {config_path} --all --mode cockpit --out-dir artifacts/perfgate
507          artifacts:
508            - artifacts/perfgate/**
509"#
510    )
511}
512
513fn scaffold_circleci(config_path: &str) -> String {
514    format!(
515        r#"# .circleci/config.yml snippet — generated by `perfgate init`
516version: 2.1
517jobs:
518  perfgate:
519    docker:
520      - image: cimg/rust:1.80
521    steps:
522      - checkout
523      - run:
524          name: Install perfgate
525          command: cargo install perfgate-cli
526      - run:
527          name: Run benchmarks
528          command: perfgate check --config {config_path} --all --mode cockpit --out-dir artifacts/perfgate
529      - store_artifacts:
530          path: artifacts/perfgate
531"#
532    )
533}
534
535/// Return the default CI workflow file path for a platform.
536pub fn ci_workflow_path(platform: CiPlatform) -> PathBuf {
537    match platform {
538        CiPlatform::GitHub => PathBuf::from(".github/workflows/perfgate.yml"),
539        CiPlatform::GitLab => PathBuf::from(".gitlab-ci.perfgate.yml"),
540        CiPlatform::Bitbucket => PathBuf::from("bitbucket-pipelines.perfgate.yml"),
541        CiPlatform::CircleCi => PathBuf::from(".circleci/perfgate.yml"),
542    }
543}
544
545// ---------------------------------------------------------------------------
546// Tests
547// ---------------------------------------------------------------------------
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use std::fs;
553
554    // -- Preset defaults ---------------------------------------------------
555
556    #[test]
557    fn preset_standard_defaults() {
558        let d = Preset::Standard.defaults();
559        assert_eq!(d.repeat, Some(5));
560        assert_eq!(d.warmup, Some(1));
561        assert_eq!(d.threshold, Some(0.20));
562    }
563
564    #[test]
565    fn preset_release_defaults() {
566        let d = Preset::Release.defaults();
567        assert_eq!(d.repeat, Some(10));
568        assert_eq!(d.warmup, Some(2));
569        assert_eq!(d.threshold, Some(0.10));
570    }
571
572    #[test]
573    fn preset_tier1fast_defaults() {
574        let d = Preset::Tier1Fast.defaults();
575        assert_eq!(d.repeat, Some(3));
576        assert_eq!(d.warmup, Some(1));
577        assert_eq!(d.threshold, Some(0.30));
578    }
579
580    // -- Rust / Cargo discovery -------------------------------------------
581
582    #[test]
583    fn discover_cargo_bench_targets() {
584        let dir = tempfile::tempdir().unwrap();
585        let cargo = dir.path().join("Cargo.toml");
586        fs::write(
587            &cargo,
588            r#"
589[package]
590name = "example"
591version = "0.1.0"
592edition = "2021"
593
594[[bench]]
595name = "my-bench"
596harness = false
597"#,
598        )
599        .unwrap();
600
601        let found = discover_benchmarks(dir.path());
602        assert_eq!(found.len(), 1);
603        assert_eq!(found[0].name, "my-bench");
604        assert_eq!(found[0].source, BenchSource::Criterion); // harness=false
605        assert_eq!(
606            found[0].command,
607            vec!["cargo", "bench", "--bench", "my-bench"]
608        );
609    }
610
611    #[test]
612    fn discover_cargo_bench_harness_true() {
613        let dir = tempfile::tempdir().unwrap();
614        let cargo = dir.path().join("Cargo.toml");
615        fs::write(
616            &cargo,
617            r#"
618[package]
619name = "example"
620version = "0.1.0"
621edition = "2021"
622
623[[bench]]
624name = "basic"
625"#,
626        )
627        .unwrap();
628
629        let found = discover_benchmarks(dir.path());
630        assert_eq!(found.len(), 1);
631        assert_eq!(found[0].source, BenchSource::CargoTarget);
632    }
633
634    #[test]
635    fn discover_criterion_from_benches_dir() {
636        let dir = tempfile::tempdir().unwrap();
637        // Need a Cargo.toml so the Rust scanner fires.
638        fs::write(
639            dir.path().join("Cargo.toml"),
640            "[package]\nname = \"x\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
641        )
642        .unwrap();
643
644        let benches_dir = dir.path().join("benches");
645        fs::create_dir(&benches_dir).unwrap();
646        fs::write(
647            benches_dir.join("perf.rs"),
648            "criterion_group!(benches, bench_fn);\ncriterion_main!(benches);\n",
649        )
650        .unwrap();
651
652        let found = discover_benchmarks(dir.path());
653        assert_eq!(found.len(), 1);
654        assert_eq!(found[0].name, "perf");
655        assert_eq!(found[0].source, BenchSource::Criterion);
656    }
657
658    #[test]
659    fn criterion_dedup_with_cargo_target() {
660        let dir = tempfile::tempdir().unwrap();
661        fs::write(
662            dir.path().join("Cargo.toml"),
663            r#"
664[package]
665name = "x"
666version = "0.1.0"
667edition = "2021"
668
669[[bench]]
670name = "perf"
671harness = false
672"#,
673        )
674        .unwrap();
675
676        let benches_dir = dir.path().join("benches");
677        fs::create_dir(&benches_dir).unwrap();
678        fs::write(
679            benches_dir.join("perf.rs"),
680            "criterion_group!(benches, bench_fn);\ncriterion_main!(benches);\n",
681        )
682        .unwrap();
683
684        let found = discover_benchmarks(dir.path());
685        // Should only appear once.
686        assert_eq!(found.len(), 1);
687        assert_eq!(found[0].name, "perf");
688    }
689
690    // -- Go discovery ------------------------------------------------------
691
692    #[test]
693    fn discover_go_benches() {
694        let dir = tempfile::tempdir().unwrap();
695        fs::write(dir.path().join("go.mod"), "module example\n").unwrap();
696        fs::write(
697            dir.path().join("bench_test.go"),
698            "package main\n\nfunc BenchmarkFoo(b *testing.B) {\n}\n",
699        )
700        .unwrap();
701
702        let found = discover_benchmarks(dir.path());
703        assert_eq!(found.len(), 1);
704        assert_eq!(found[0].name, "go-bench");
705        assert_eq!(found[0].source, BenchSource::GoBench);
706        assert!(found[0].command.contains(&"-bench=.".to_string()));
707    }
708
709    #[test]
710    fn discover_go_benches_in_subpackage() {
711        let dir = tempfile::tempdir().unwrap();
712        fs::write(dir.path().join("go.mod"), "module example\n").unwrap();
713        let sub = dir.path().join("pkg").join("fast");
714        fs::create_dir_all(&sub).unwrap();
715        fs::write(
716            sub.join("bench_test.go"),
717            "package fast\nfunc BenchmarkBar(b *testing.B) {}\n",
718        )
719        .unwrap();
720
721        let found = discover_benchmarks(dir.path());
722        assert_eq!(found.len(), 1);
723        assert_eq!(found[0].name, "go-bench-pkg-fast");
724    }
725
726    // -- Python discovery --------------------------------------------------
727
728    #[test]
729    fn discover_pytest_benchmark_from_requirements() {
730        let dir = tempfile::tempdir().unwrap();
731        fs::write(
732            dir.path().join("requirements.txt"),
733            "pytest\npytest-benchmark\n",
734        )
735        .unwrap();
736
737        let found = discover_benchmarks(dir.path());
738        assert_eq!(found.len(), 1);
739        assert_eq!(found[0].name, "pytest-bench");
740        assert_eq!(found[0].source, BenchSource::PytestBenchmark);
741    }
742
743    #[test]
744    fn discover_pytest_benchmark_from_pyproject() {
745        let dir = tempfile::tempdir().unwrap();
746        fs::write(
747            dir.path().join("pyproject.toml"),
748            "[project.optional-dependencies]\ntest = [\"pytest-benchmark\"]\n",
749        )
750        .unwrap();
751
752        let found = discover_benchmarks(dir.path());
753        assert_eq!(found.len(), 1);
754        assert_eq!(found[0].source, BenchSource::PytestBenchmark);
755    }
756
757    #[test]
758    fn discover_pytest_benchmark_from_conftest() {
759        let dir = tempfile::tempdir().unwrap();
760        fs::write(
761            dir.path().join("conftest.py"),
762            "def test_speed(benchmark):\n    benchmark(lambda: None)\n",
763        )
764        .unwrap();
765
766        let found = discover_benchmarks(dir.path());
767        assert_eq!(found.len(), 1);
768    }
769
770    // -- Empty repo --------------------------------------------------------
771
772    #[test]
773    fn empty_repo_discovers_nothing() {
774        let dir = tempfile::tempdir().unwrap();
775        let found = discover_benchmarks(dir.path());
776        assert!(found.is_empty());
777    }
778
779    // -- Config generation -------------------------------------------------
780
781    #[test]
782    fn generate_config_produces_valid_toml() {
783        let benches = vec![
784            DiscoveredBench {
785                name: "my-bench".into(),
786                command: vec!["cargo".into(), "bench".into()],
787                source: BenchSource::CargoTarget,
788            },
789            DiscoveredBench {
790                name: "go-bench".into(),
791                command: vec!["go".into(), "test".into(), "-bench=.".into(), ".".into()],
792                source: BenchSource::GoBench,
793            },
794        ];
795
796        let config = generate_config(&benches, Preset::Standard);
797        assert_eq!(config.benches.len(), 2);
798        assert_eq!(config.defaults.repeat, Some(5));
799        assert_eq!(config.defaults.threshold, Some(0.20));
800    }
801
802    #[test]
803    fn render_config_toml_roundtrip() {
804        let benches = vec![DiscoveredBench {
805            name: "my-bench".into(),
806            command: vec![
807                "cargo".into(),
808                "bench".into(),
809                "--bench".into(),
810                "my-bench".into(),
811            ],
812            source: BenchSource::CargoTarget,
813        }];
814
815        let config = generate_config(&benches, Preset::Release);
816        let toml_str = render_config_toml(&config);
817
818        // The rendered TOML must parse back without error.
819        let parsed: ConfigFile = toml::from_str(&toml_str).expect("rendered TOML should parse");
820        assert_eq!(parsed.benches.len(), 1);
821        assert_eq!(parsed.benches[0].name, "my-bench");
822        assert_eq!(parsed.defaults.repeat, Some(10));
823        assert_eq!(parsed.defaults.threshold, Some(0.10));
824    }
825
826    // -- CI scaffold -------------------------------------------------------
827
828    #[test]
829    fn scaffold_github_ci() {
830        let content = scaffold_ci(CiPlatform::GitHub, Path::new("perfgate.toml"));
831        assert!(content.contains("perfgate check"));
832        assert!(content.contains("perfgate.toml"));
833        assert!(content.contains("ubuntu-latest"));
834    }
835
836    #[test]
837    fn scaffold_gitlab_ci() {
838        let content = scaffold_ci(CiPlatform::GitLab, Path::new("perfgate.toml"));
839        assert!(content.contains("perfgate check"));
840        assert!(content.contains("stage: test"));
841    }
842
843    #[test]
844    fn scaffold_bitbucket_ci() {
845        let content = scaffold_ci(CiPlatform::Bitbucket, Path::new("perfgate.toml"));
846        assert!(content.contains("perfgate check"));
847        assert!(content.contains("pipelines"));
848    }
849
850    #[test]
851    fn scaffold_circleci_ci() {
852        let content = scaffold_ci(CiPlatform::CircleCi, Path::new("perfgate.toml"));
853        assert!(content.contains("perfgate check"));
854        assert!(content.contains("version: 2.1"));
855    }
856
857    #[test]
858    fn ci_workflow_paths() {
859        assert_eq!(
860            ci_workflow_path(CiPlatform::GitHub),
861            PathBuf::from(".github/workflows/perfgate.yml")
862        );
863        assert_eq!(
864            ci_workflow_path(CiPlatform::GitLab),
865            PathBuf::from(".gitlab-ci.perfgate.yml")
866        );
867    }
868
869    // -- BenchSource display -----------------------------------------------
870
871    #[test]
872    fn bench_source_display() {
873        assert_eq!(
874            format!("{}", BenchSource::CargoTarget),
875            "cargo bench target"
876        );
877        assert_eq!(format!("{}", BenchSource::Criterion), "criterion benchmark");
878        assert_eq!(format!("{}", BenchSource::GoBench), "go benchmark");
879        assert_eq!(
880            format!("{}", BenchSource::PytestBenchmark),
881            "pytest-benchmark"
882        );
883        assert_eq!(format!("{}", BenchSource::Custom), "custom");
884    }
885
886    // -- Mixed repo discovery ----------------------------------------------
887
888    #[test]
889    fn discover_mixed_repo() {
890        let dir = tempfile::tempdir().unwrap();
891
892        // Rust
893        fs::write(
894            dir.path().join("Cargo.toml"),
895            r#"
896[package]
897name = "mixed"
898version = "0.1.0"
899edition = "2021"
900
901[[bench]]
902name = "rust-bench"
903harness = false
904"#,
905        )
906        .unwrap();
907
908        // Go
909        fs::write(dir.path().join("go.mod"), "module mixed\n").unwrap();
910        fs::write(
911            dir.path().join("bench_test.go"),
912            "package main\nfunc BenchmarkX(b *testing.B) {}\n",
913        )
914        .unwrap();
915
916        // Python
917        fs::write(dir.path().join("requirements.txt"), "pytest-benchmark\n").unwrap();
918
919        let found = discover_benchmarks(dir.path());
920        assert_eq!(found.len(), 3);
921
922        let names: Vec<&str> = found.iter().map(|b| b.name.as_str()).collect();
923        assert!(names.contains(&"rust-bench"));
924        assert!(names.contains(&"go-bench"));
925        assert!(names.contains(&"pytest-bench"));
926    }
927}