Skip to main content

forge/cmd/
snapshot.rs

1use super::test;
2use crate::result::{SuiteTestResult, TestKindReport, TestOutcome};
3use alloy_primitives::{U256, map::HashMap};
4use clap::{Parser, ValueHint, builder::RangedU64ValueParser};
5use comfy_table::{
6    Cell, Color, Row, Table,
7    presets::{ASCII_FULL, ASCII_MARKDOWN},
8};
9use eyre::{Context, Result};
10use foundry_cli::utils::STATIC_FUZZ_SEED;
11use foundry_common::shell;
12use regex::Regex;
13use std::{
14    cmp::Ordering,
15    fs,
16    io::{self, BufRead},
17    path::{Path, PathBuf},
18    str::FromStr,
19    sync::LazyLock,
20};
21use yansi::Paint;
22
23/// A regex that matches a basic snapshot entry like
24/// `Test:testDeposit() (gas: 58804)`
25pub static RE_BASIC_SNAPSHOT_ENTRY: LazyLock<Regex> = LazyLock::new(|| {
26    Regex::new(r"(?P<file>(.*?)):(?P<sig>(\w+)\s*\((.*?)\))\s*\(((gas:)?\s*(?P<gas>\d+)|(runs:\s*(?P<runs>\d+),\s*μ:\s*(?P<avg>\d+),\s*~:\s*(?P<med>\d+))|(runs:\s*(?P<invruns>\d+),\s*calls:\s*(?P<calls>\d+),\s*reverts:\s*(?P<reverts>\d+)))\)").unwrap()
27});
28
29/// CLI arguments for `forge snapshot`.
30#[derive(Clone, Debug, Parser)]
31pub struct GasSnapshotArgs {
32    /// Output a diff against a pre-existing gas snapshot.
33    ///
34    /// By default, the comparison is done with .gas-snapshot.
35    #[arg(
36        conflicts_with = "snap",
37        long,
38        value_hint = ValueHint::FilePath,
39        value_name = "SNAPSHOT_FILE",
40    )]
41    diff: Option<Option<PathBuf>>,
42
43    /// Compare against a pre-existing gas snapshot, exiting with code 1 if they do not match.
44    ///
45    /// Outputs a diff if the gas snapshots do not match.
46    ///
47    /// By default, the comparison is done with .gas-snapshot.
48    #[arg(
49        conflicts_with = "diff",
50        long,
51        value_hint = ValueHint::FilePath,
52        value_name = "SNAPSHOT_FILE",
53    )]
54    check: Option<Option<PathBuf>>,
55
56    // Hidden because there is only one option
57    /// How to format the output.
58    #[arg(long, hide(true))]
59    format: Option<Format>,
60
61    /// Output file for the gas snapshot.
62    #[arg(
63        long,
64        default_value = ".gas-snapshot",
65        value_hint = ValueHint::FilePath,
66        value_name = "FILE",
67    )]
68    snap: PathBuf,
69
70    /// Tolerates gas deviations up to the specified percentage.
71    #[arg(
72        long,
73        value_parser = RangedU64ValueParser::<u32>::new().range(0..100),
74        value_name = "SNAPSHOT_THRESHOLD"
75    )]
76    tolerance: Option<u32>,
77
78    /// How to sort diff results.
79    #[arg(long, value_name = "ORDER")]
80    diff_sort: Option<DiffSortOrder>,
81
82    /// All test arguments are supported
83    #[command(flatten)]
84    pub(crate) test: test::TestArgs,
85
86    /// Additional configs for test results
87    #[command(flatten)]
88    config: GasSnapshotConfig,
89}
90
91impl GasSnapshotArgs {
92    /// Returns whether `GasSnapshotArgs` was configured with `--watch`
93    pub const fn is_watch(&self) -> bool {
94        self.test.is_watch()
95    }
96
97    /// Returns the [`watchexec::Config`] necessary to bootstrap a new watch loop.
98    pub(crate) fn watchexec_config(&self) -> Result<watchexec::Config> {
99        self.test.watchexec_config()
100    }
101
102    pub async fn run(mut self) -> Result<()> {
103        // Default to a static fuzz seed so gas snapshots are deterministic,
104        // but allow the user to override it via `--fuzz-seed`.
105        if self.test.fuzz_seed.is_none() {
106            self.test.fuzz_seed = Some(U256::from_be_bytes(STATIC_FUZZ_SEED));
107        }
108
109        let outcome = self.test.compile_and_run().await?;
110        if !shell::is_quiet()
111            && !outcome.allow_failure
112            && self.diff.is_none()
113            && self.check.is_none()
114            && outcome.failed() > 0
115        {
116            sh_eprintln!(
117                "Error: gas snapshot file \"{}\" was not written because the test run failed",
118                self.snap.display()
119            )?;
120        }
121        outcome.ensure_ok(false)?;
122        let tests = self.config.apply(outcome);
123
124        if let Some(path) = self.diff {
125            let snap = path.as_ref().unwrap_or(&self.snap);
126            let snaps = read_gas_snapshot(snap)?;
127            diff(tests, snaps, self.diff_sort.unwrap_or_default())?;
128        } else if let Some(path) = self.check {
129            let snap = path.as_ref().unwrap_or(&self.snap);
130            let snaps = read_gas_snapshot(snap)?;
131            let code = if check(tests, snaps, self.tolerance) { 0 } else { 1 };
132            std::process::exit(code)
133        } else {
134            if matches!(self.format, Some(Format::Table)) {
135                let table = build_gas_snapshot_table(&tests);
136                sh_println!("\n{}", table)?;
137            }
138            write_to_gas_snapshot_file(&tests, self.snap, self.format)?;
139        }
140        Ok(())
141    }
142}
143
144// Gas report format on stdout.
145#[derive(Clone, Debug)]
146pub enum Format {
147    Table,
148}
149
150impl FromStr for Format {
151    type Err = String;
152
153    fn from_str(s: &str) -> Result<Self, Self::Err> {
154        match s {
155            "t" | "table" => Ok(Self::Table),
156            _ => Err(format!("Unrecognized format `{s}`")),
157        }
158    }
159}
160
161/// Additional filters that can be applied on the test results
162#[derive(Clone, Debug, Default, Parser)]
163struct GasSnapshotConfig {
164    /// Sort results by gas used (ascending).
165    #[arg(long)]
166    asc: bool,
167
168    /// Sort results by gas used (descending).
169    #[arg(conflicts_with = "asc", long)]
170    desc: bool,
171
172    /// Only include tests that used more gas that the given amount.
173    #[arg(long, value_name = "MIN_GAS")]
174    min: Option<u64>,
175
176    /// Only include tests that used less gas that the given amount.
177    #[arg(long, value_name = "MAX_GAS")]
178    max: Option<u64>,
179}
180
181/// Sort order for diff output
182#[derive(Clone, Debug, Default, clap::ValueEnum)]
183enum DiffSortOrder {
184    /// Sort by percentage change (smallest to largest) - default behavior
185    #[default]
186    Percentage,
187    /// Sort by percentage change (largest to smallest)
188    PercentageDesc,
189    /// Sort by absolute gas change (smallest to largest)
190    Absolute,
191    /// Sort by absolute gas change (largest to smallest)
192    AbsoluteDesc,
193}
194
195impl GasSnapshotConfig {
196    const fn is_in_gas_range(&self, gas_used: u64) -> bool {
197        if let Some(min) = self.min
198            && gas_used < min
199        {
200            return false;
201        }
202        if let Some(max) = self.max
203            && gas_used > max
204        {
205            return false;
206        }
207        true
208    }
209
210    fn apply(&self, outcome: TestOutcome) -> Vec<SuiteTestResult> {
211        let mut tests = outcome
212            .into_tests()
213            .filter(|test| self.is_in_gas_range(test.gas_used()))
214            .flat_map(expand_invariant_snapshot_entries)
215            .collect::<Vec<_>>();
216
217        if self.asc {
218            tests.sort_by_key(|a| a.gas_used());
219        } else if self.desc {
220            tests.sort_by_key(|b| std::cmp::Reverse(b.gas_used()))
221        }
222
223        tests
224    }
225}
226
227/// Expands merged invariant campaigns into per-predicate gas snapshot rows.
228fn expand_invariant_snapshot_entries(test: SuiteTestResult) -> Vec<SuiteTestResult> {
229    if !test.result.kind.is_invariant() || test.result.invariant_predicate_results.len() <= 1 {
230        return vec![test];
231    }
232
233    test.result
234        .invariant_predicate_results
235        .iter()
236        .map(|predicate| {
237            let mut expanded = test.clone();
238            expanded.signature = format!("{}()", predicate.name);
239            expanded
240        })
241        .collect()
242}
243
244/// A general entry in a gas snapshot file
245///
246/// Has the form:
247///   `<signature>(gas:? 40181)` for normal tests
248///   `<signature>(runs: 256, μ: 40181, ~: 40181)` for fuzz tests
249///   `<signature>(runs: 256, calls: 40181, reverts: 40181)` for invariant tests
250#[derive(Clone, Debug, PartialEq, Eq)]
251pub struct GasSnapshotEntry {
252    pub contract_name: String,
253    pub signature: String,
254    pub gas_used: TestKindReport,
255}
256
257impl FromStr for GasSnapshotEntry {
258    type Err = String;
259
260    fn from_str(s: &str) -> Result<Self, Self::Err> {
261        let cap = RE_BASIC_SNAPSHOT_ENTRY
262            .captures(s)
263            .ok_or_else(|| format!("Could not extract Snapshot Entry for {s}"))?;
264        let contract_name = cap
265            .name("file")
266            .ok_or_else(|| format!("Could not extract Snapshot Entry for {s}"))?
267            .as_str()
268            .to_string();
269        let signature = cap
270            .name("sig")
271            .ok_or_else(|| format!("Could not extract Snapshot Entry for {s}"))?
272            .as_str()
273            .to_string();
274
275        let gas_used = if let Some(gas) = cap.name("gas") {
276            TestKindReport::Unit { gas: parse_snapshot_value(gas.as_str(), "gas")? }
277        } else if let Some(runs) = cap.name("runs") {
278            let avg = cap
279                .name("avg")
280                .ok_or_else(|| format!("Could not extract Snapshot Entry for {s}"))?;
281            let med = cap
282                .name("med")
283                .ok_or_else(|| format!("Could not extract Snapshot Entry for {s}"))?;
284            TestKindReport::Fuzz {
285                runs: parse_snapshot_value(runs.as_str(), "runs")?,
286                median_gas: parse_snapshot_value(med.as_str(), "median gas")?,
287                mean_gas: parse_snapshot_value(avg.as_str(), "mean gas")?,
288                failed_corpus_replays: 0,
289            }
290        } else {
291            let runs = cap
292                .name("invruns")
293                .ok_or_else(|| format!("Could not extract Snapshot Entry for {s}"))?;
294            let calls = cap
295                .name("calls")
296                .ok_or_else(|| format!("Could not extract Snapshot Entry for {s}"))?;
297            let reverts = cap
298                .name("reverts")
299                .ok_or_else(|| format!("Could not extract Snapshot Entry for {s}"))?;
300            TestKindReport::Invariant {
301                runs: parse_snapshot_value(runs.as_str(), "runs")?,
302                calls: parse_snapshot_value(calls.as_str(), "calls")?,
303                reverts: parse_snapshot_value(reverts.as_str(), "reverts")?,
304                failed_corpus_replays: 0,
305                optimization_best_value: None,
306            }
307        };
308
309        Ok(Self { contract_name, signature, gas_used })
310    }
311}
312
313fn parse_snapshot_value<T: FromStr>(value: &str, field: &str) -> Result<T, String> {
314    value.parse().map_err(|_| format!("invalid {field} value `{value}` in gas snapshot"))
315}
316
317/// Reads a list of gas snapshot entries from a gas snapshot file.
318fn read_gas_snapshot(path: impl AsRef<Path>) -> Result<Vec<GasSnapshotEntry>> {
319    let path = path.as_ref();
320    let mut entries = Vec::new();
321    for (line_number, line) in io::BufReader::new(
322        fs::File::open(path)
323            .wrap_err(format!("failed to read snapshot file \"{}\"", path.display()))?,
324    )
325    .lines()
326    .enumerate()
327    {
328        let line_number = line_number + 1;
329        let line = line.wrap_err_with(|| {
330            format!("failed to read snapshot file \"{}\" at line {line_number}", path.display())
331        })?;
332        let entry = GasSnapshotEntry::from_str(&line)
333            .map_err(|err| eyre::eyre!(err))
334            .wrap_err_with(|| {
335                format!(
336                    "failed to parse snapshot file \"{}\" at line {line_number}",
337                    path.display()
338                )
339            })?;
340        entries.push(entry);
341    }
342    Ok(entries)
343}
344
345/// Writes a series of tests to a gas snapshot file after sorting them.
346fn write_to_gas_snapshot_file(
347    tests: &[SuiteTestResult],
348    path: impl AsRef<Path>,
349    _format: Option<Format>,
350) -> Result<()> {
351    let mut reports = tests
352        .iter()
353        .map(|test| {
354            format!("{}:{} {}", test.contract_name(), test.signature, test.result.kind.report())
355        })
356        .collect::<Vec<_>>();
357
358    // sort all reports
359    reports.sort();
360
361    let content = reports.join("\n");
362    Ok(fs::write(path, content)?)
363}
364
365fn build_gas_snapshot_table(tests: &[SuiteTestResult]) -> Table {
366    let mut table = Table::new();
367    if shell::is_markdown() {
368        table.load_style(ASCII_MARKDOWN);
369    } else {
370        table.load_style(ASCII_FULL.with_rounded_corners());
371    }
372
373    table.set_header(vec![
374        Cell::new("Contract").fg(Color::Cyan),
375        Cell::new("Signature").fg(Color::Cyan),
376        Cell::new("Report").fg(Color::Cyan),
377    ]);
378
379    for test in tests {
380        let mut row = Row::new();
381        row.add_cell(Cell::new(test.contract_name()));
382        row.add_cell(Cell::new(&test.signature));
383        row.add_cell(Cell::new(test.result.kind.report()));
384        table.add_row(row);
385    }
386
387    table
388}
389
390/// A Gas snapshot entry diff.
391#[derive(Clone, Debug, PartialEq, Eq)]
392pub struct GasSnapshotDiff {
393    pub signature: String,
394    pub source_gas_used: TestKindReport,
395    pub target_gas_used: TestKindReport,
396}
397
398impl GasSnapshotDiff {
399    /// Returns the gas diff
400    ///
401    /// `> 0` if the source used more gas
402    /// `< 0` if the target used more gas
403    const fn gas_change(&self) -> i128 {
404        self.source_gas_used.gas() as i128 - self.target_gas_used.gas() as i128
405    }
406
407    /// Determines the percentage change
408    fn gas_diff(&self) -> f64 {
409        let target_gas = self.target_gas_used.gas();
410        if target_gas > 0 {
411            self.gas_change() as f64 / target_gas as f64
412        } else if self.source_gas_used.gas() == 0 {
413            // No percentage change when both values are zero.
414            0.0
415        } else {
416            // Preserve an unbounded increase from zero.
417            f64::INFINITY
418        }
419    }
420}
421
422/// Compares the set of tests with an existing gas snapshot.
423///
424/// Returns true all tests match
425fn check(
426    tests: Vec<SuiteTestResult>,
427    snaps: Vec<GasSnapshotEntry>,
428    tolerance: Option<u32>,
429) -> bool {
430    let snaps = snaps
431        .into_iter()
432        .map(|s| ((s.contract_name, s.signature), s.gas_used))
433        .collect::<HashMap<_, _>>();
434    let mut has_diff = false;
435    for test in tests {
436        if let Some(target_gas) =
437            snaps.get(&(test.contract_name().to_string(), test.signature.clone())).cloned()
438        {
439            let source_gas = test.result.kind.report();
440            if !within_tolerance(source_gas.gas(), target_gas.gas(), tolerance) {
441                let _ = sh_eprintln!(
442                    "Diff in \"{}::{}\": consumed \"{}\" gas, expected \"{}\" gas ",
443                    test.contract_name(),
444                    test.signature,
445                    source_gas,
446                    target_gas
447                );
448                has_diff = true;
449            }
450        } else {
451            let _ = sh_eprintln!(
452                "No matching snapshot entry found for \"{}::{}\" in snapshot file",
453                test.contract_name(),
454                test.signature
455            );
456            has_diff = true;
457        }
458    }
459    !has_diff
460}
461
462/// Compare the set of tests with an existing gas snapshot.
463fn diff(
464    tests: Vec<SuiteTestResult>,
465    snaps: Vec<GasSnapshotEntry>,
466    sort_order: DiffSortOrder,
467) -> Result<()> {
468    let snaps = snaps
469        .into_iter()
470        .map(|s| ((s.contract_name, s.signature), s.gas_used))
471        .collect::<HashMap<_, _>>();
472    let mut diffs = Vec::with_capacity(tests.len());
473    let mut new_tests = Vec::new();
474
475    for test in tests {
476        if let Some(target_gas_used) =
477            snaps.get(&(test.contract_name().to_string(), test.signature.clone())).cloned()
478        {
479            diffs.push(GasSnapshotDiff {
480                source_gas_used: test.result.kind.report(),
481                signature: format!("{}::{}", test.contract_name(), test.signature),
482                target_gas_used,
483            });
484        } else {
485            // Track new tests
486            new_tests.push(format!("{}::{}", test.contract_name(), test.signature));
487        }
488    }
489
490    let mut increased = 0;
491    let mut decreased = 0;
492    let mut unchanged = 0;
493    let mut overall_gas_change = 0i128;
494    let mut overall_gas_used = 0i128;
495
496    // Sort based on user preference
497    match sort_order {
498        DiffSortOrder::Percentage => {
499            // Default: sort by percentage change (smallest to largest)
500            diffs.sort_by(|a, b| a.gas_diff().abs().total_cmp(&b.gas_diff().abs()));
501        }
502        DiffSortOrder::PercentageDesc => {
503            // Sort by percentage change (largest to smallest)
504            diffs.sort_by(|a, b| b.gas_diff().abs().total_cmp(&a.gas_diff().abs()));
505        }
506        DiffSortOrder::Absolute => {
507            // Sort by absolute gas change (smallest to largest)
508            diffs.sort_by_key(|d| d.gas_change().abs());
509        }
510        DiffSortOrder::AbsoluteDesc => {
511            // Sort by absolute gas change (largest to smallest)
512            diffs.sort_by_key(|d| std::cmp::Reverse(d.gas_change().abs()));
513        }
514    }
515
516    for diff in &diffs {
517        let gas_change = diff.gas_change();
518        overall_gas_change += gas_change;
519        overall_gas_used += diff.target_gas_used.gas() as i128;
520        let gas_diff = diff.gas_diff();
521
522        // Classify changes
523        if gas_change > 0 {
524            increased += 1;
525        } else if gas_change < 0 {
526            decreased += 1;
527        } else {
528            unchanged += 1;
529        }
530
531        // Display with icon and before/after values
532        let icon = if gas_change > 0 {
533            "↑".red().to_string()
534        } else if gas_change < 0 {
535            "↓".green().to_string()
536        } else {
537            "━".to_string()
538        };
539
540        sh_println!(
541            "{} {} (gas: {} → {} | {} {})",
542            icon,
543            diff.signature,
544            diff.target_gas_used.gas(),
545            diff.source_gas_used.gas(),
546            fmt_change(gas_change),
547            fmt_pct_change(gas_diff)
548        )?;
549    }
550
551    // Display new tests if any
552    if !new_tests.is_empty() {
553        sh_eprintln!("\n{}", "New tests:".yellow())?;
554        for test in new_tests {
555            sh_eprintln!("  {} {}", "+".green(), test)?;
556        }
557    }
558
559    // Summary separator
560    sh_eprintln!("\n{}", "-".repeat(80))?;
561
562    let overall_gas_diff = if overall_gas_used > 0 {
563        overall_gas_change as f64 / overall_gas_used as f64
564    } else {
565        0.0
566    };
567
568    sh_eprintln!(
569        "Total tests: {}, {} {}, {} {}, {} {}",
570        diffs.len(),
571        "↑".red().to_string(),
572        increased,
573        "↓".green().to_string(),
574        decreased,
575        "━",
576        unchanged
577    )?;
578    sh_eprintln!(
579        "Overall gas change: {} ({})",
580        fmt_change(overall_gas_change),
581        fmt_pct_change(overall_gas_diff)
582    )?;
583    Ok(())
584}
585
586fn fmt_pct_change(change: f64) -> String {
587    let change_pct = change * 100.0;
588    match change.total_cmp(&0.0) {
589        Ordering::Less => format!("{change_pct:.3}%").green().to_string(),
590        Ordering::Equal => {
591            format!("{change_pct:.3}%")
592        }
593        Ordering::Greater => format!("{change_pct:.3}%").red().to_string(),
594    }
595}
596
597fn fmt_change(change: i128) -> String {
598    match change.cmp(&0) {
599        Ordering::Less => format!("{change}").green().to_string(),
600        Ordering::Equal => change.to_string(),
601        Ordering::Greater => format!("{change}").red().to_string(),
602    }
603}
604
605/// Returns true of the difference between the gas values exceeds the tolerance
606///
607/// If `tolerance` is `None`, then this returns `true` if both gas values are equal
608fn within_tolerance(source_gas: u64, target_gas: u64, tolerance_pct: Option<u32>) -> bool {
609    if let Some(tolerance) = tolerance_pct {
610        let (hi, lo) = if source_gas > target_gas {
611            (source_gas, target_gas)
612        } else {
613            (target_gas, source_gas)
614        };
615        if hi == 0 {
616            // No percentage difference when both values are zero.
617            return true;
618        }
619        let diff = (1. - (lo as f64 / hi as f64)) * 100.;
620        diff < tolerance as f64
621    } else {
622        source_gas == target_gas
623    }
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    #[test]
631    fn test_tolerance() {
632        assert!(within_tolerance(100, 105, Some(5)));
633        assert!(within_tolerance(105, 100, Some(5)));
634        assert!(!within_tolerance(100, 106, Some(5)));
635        assert!(!within_tolerance(106, 100, Some(5)));
636        assert!(within_tolerance(100, 100, None));
637        assert!(within_tolerance(0, 0, Some(5)));
638    }
639
640    #[test]
641    fn can_parse_basic_gas_snapshot_entry() {
642        let s = "Test:deposit() (gas: 7222)";
643        let entry = GasSnapshotEntry::from_str(s).unwrap();
644        assert_eq!(
645            entry,
646            GasSnapshotEntry {
647                contract_name: "Test".to_string(),
648                signature: "deposit()".to_string(),
649                gas_used: TestKindReport::Unit { gas: 7222 }
650            }
651        );
652    }
653
654    #[test]
655    fn can_parse_fuzz_gas_snapshot_entry() {
656        let s = "Test:deposit() (runs: 256, μ: 100, ~:200)";
657        let entry = GasSnapshotEntry::from_str(s).unwrap();
658        assert_eq!(
659            entry,
660            GasSnapshotEntry {
661                contract_name: "Test".to_string(),
662                signature: "deposit()".to_string(),
663                gas_used: TestKindReport::Fuzz {
664                    runs: 256,
665                    median_gas: 200,
666                    mean_gas: 100,
667                    failed_corpus_replays: 0
668                }
669            }
670        );
671    }
672
673    #[test]
674    fn can_parse_invariant_gas_snapshot_entry() {
675        let s = "Test:deposit() (runs: 256, calls: 100, reverts: 200)";
676        let entry = GasSnapshotEntry::from_str(s).unwrap();
677        assert_eq!(
678            entry,
679            GasSnapshotEntry {
680                contract_name: "Test".to_string(),
681                signature: "deposit()".to_string(),
682                gas_used: TestKindReport::Invariant {
683                    runs: 256,
684                    calls: 100,
685                    reverts: 200,
686                    failed_corpus_replays: 0,
687                    optimization_best_value: None,
688                }
689            }
690        );
691    }
692
693    #[test]
694    fn can_parse_invariant_gas_snapshot_entry2() {
695        let s = "ERC20Invariants:invariantBalanceSum() (runs: 256, calls: 3840, reverts: 2388)";
696        let entry = GasSnapshotEntry::from_str(s).unwrap();
697        assert_eq!(
698            entry,
699            GasSnapshotEntry {
700                contract_name: "ERC20Invariants".to_string(),
701                signature: "invariantBalanceSum()".to_string(),
702                gas_used: TestKindReport::Invariant {
703                    runs: 256,
704                    calls: 3840,
705                    reverts: 2388,
706                    failed_corpus_replays: 0,
707                    optimization_best_value: None,
708                }
709            }
710        );
711    }
712
713    #[test]
714    fn rejects_overflowing_gas_snapshot_values() {
715        let u64_overflow = (u64::MAX as u128 + 1).to_string();
716        let usize_overflow = (usize::MAX as u128 + 1).to_string();
717        let cases = [
718            (
719                format!("Example:testFoo() (gas: {u64_overflow})"),
720                format!("invalid gas value `{u64_overflow}` in gas snapshot"),
721            ),
722            (
723                format!("Example:testFoo() (runs: {usize_overflow}, μ: 1, ~: 1)"),
724                format!("invalid runs value `{usize_overflow}` in gas snapshot"),
725            ),
726            (
727                format!("Example:testFoo() (runs: 1, μ: {u64_overflow}, ~: 1)"),
728                format!("invalid mean gas value `{u64_overflow}` in gas snapshot"),
729            ),
730            (
731                format!("Example:testFoo() (runs: 1, μ: 1, ~: {u64_overflow})"),
732                format!("invalid median gas value `{u64_overflow}` in gas snapshot"),
733            ),
734            (
735                format!("Example:invariantFoo() (runs: {usize_overflow}, calls: 1, reverts: 1)"),
736                format!("invalid runs value `{usize_overflow}` in gas snapshot"),
737            ),
738            (
739                format!("Example:invariantFoo() (runs: 1, calls: {usize_overflow}, reverts: 1)"),
740                format!("invalid calls value `{usize_overflow}` in gas snapshot"),
741            ),
742            (
743                format!("Example:invariantFoo() (runs: 1, calls: 1, reverts: {usize_overflow})"),
744                format!("invalid reverts value `{usize_overflow}` in gas snapshot"),
745            ),
746        ];
747
748        for (snapshot, expected) in cases {
749            assert_eq!(GasSnapshotEntry::from_str(&snapshot).unwrap_err(), expected);
750        }
751    }
752
753    #[test]
754    fn accepts_maximum_gas_snapshot_values() {
755        let unit = format!("Example:testFoo() (gas: {})", u64::MAX);
756        assert_eq!(
757            GasSnapshotEntry::from_str(&unit).unwrap().gas_used,
758            TestKindReport::Unit { gas: u64::MAX }
759        );
760
761        let fuzz =
762            format!("Example:testFoo() (runs: {}, μ: {}, ~: {})", usize::MAX, u64::MAX, u64::MAX);
763        assert_eq!(
764            GasSnapshotEntry::from_str(&fuzz).unwrap().gas_used,
765            TestKindReport::Fuzz {
766                runs: usize::MAX,
767                median_gas: u64::MAX,
768                mean_gas: u64::MAX,
769                failed_corpus_replays: 0,
770            }
771        );
772
773        let invariant = format!(
774            "Example:invariantFoo() (runs: {}, calls: {}, reverts: {})",
775            usize::MAX,
776            usize::MAX,
777            usize::MAX
778        );
779        assert_eq!(
780            GasSnapshotEntry::from_str(&invariant).unwrap().gas_used,
781            TestKindReport::Invariant {
782                runs: usize::MAX,
783                calls: usize::MAX,
784                reverts: usize::MAX,
785                failed_corpus_replays: 0,
786                optimization_best_value: None,
787            }
788        );
789    }
790
791    #[test]
792    fn reports_snapshot_path_and_line_for_invalid_values() {
793        let snapshot = tempfile::NamedTempFile::new().unwrap();
794        fs::write(
795            snapshot.path(),
796            "Example:testFoo() (gas: 1)\nExample:testBar() (gas: 18446744073709551616)",
797        )
798        .unwrap();
799
800        let error = read_gas_snapshot(snapshot.path()).unwrap_err();
801
802        assert_eq!(
803            format!("{error:#}"),
804            format!(
805                "failed to parse snapshot file \"{}\" at line 2: invalid gas value `18446744073709551616` in gas snapshot",
806                snapshot.path().display()
807            )
808        );
809    }
810}