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
23pub 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#[derive(Clone, Debug, Parser)]
31pub struct GasSnapshotArgs {
32 #[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 #[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 #[arg(long, hide(true))]
59 format: Option<Format>,
60
61 #[arg(
63 long,
64 default_value = ".gas-snapshot",
65 value_hint = ValueHint::FilePath,
66 value_name = "FILE",
67 )]
68 snap: PathBuf,
69
70 #[arg(
72 long,
73 value_parser = RangedU64ValueParser::<u32>::new().range(0..100),
74 value_name = "SNAPSHOT_THRESHOLD"
75 )]
76 tolerance: Option<u32>,
77
78 #[arg(long, value_name = "ORDER")]
80 diff_sort: Option<DiffSortOrder>,
81
82 #[command(flatten)]
84 pub(crate) test: test::TestArgs,
85
86 #[command(flatten)]
88 config: GasSnapshotConfig,
89}
90
91impl GasSnapshotArgs {
92 pub const fn is_watch(&self) -> bool {
94 self.test.is_watch()
95 }
96
97 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 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#[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#[derive(Clone, Debug, Default, Parser)]
163struct GasSnapshotConfig {
164 #[arg(long)]
166 asc: bool,
167
168 #[arg(conflicts_with = "asc", long)]
170 desc: bool,
171
172 #[arg(long, value_name = "MIN_GAS")]
174 min: Option<u64>,
175
176 #[arg(long, value_name = "MAX_GAS")]
178 max: Option<u64>,
179}
180
181#[derive(Clone, Debug, Default, clap::ValueEnum)]
183enum DiffSortOrder {
184 #[default]
186 Percentage,
187 PercentageDesc,
189 Absolute,
191 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
227fn 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#[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 RE_BASIC_SNAPSHOT_ENTRY
262 .captures(s)
263 .and_then(|cap| {
264 cap.name("file").and_then(|file| {
265 cap.name("sig").and_then(|sig| {
266 if let Some(gas) = cap.name("gas") {
267 Some(Self {
268 contract_name: file.as_str().to_string(),
269 signature: sig.as_str().to_string(),
270 gas_used: TestKindReport::Unit {
271 gas: gas.as_str().parse().unwrap(),
272 },
273 })
274 } else if let Some(runs) = cap.name("runs") {
275 cap.name("avg")
276 .and_then(|avg| cap.name("med").map(|med| (runs, avg, med)))
277 .map(|(runs, avg, med)| Self {
278 contract_name: file.as_str().to_string(),
279 signature: sig.as_str().to_string(),
280 gas_used: TestKindReport::Fuzz {
281 runs: runs.as_str().parse().unwrap(),
282 median_gas: med.as_str().parse().unwrap(),
283 mean_gas: avg.as_str().parse().unwrap(),
284 failed_corpus_replays: 0,
285 },
286 })
287 } else {
288 cap.name("invruns")
289 .and_then(|runs| {
290 cap.name("calls").and_then(|avg| {
291 cap.name("reverts").map(|med| (runs, avg, med))
292 })
293 })
294 .map(|(runs, calls, reverts)| Self {
295 contract_name: file.as_str().to_string(),
296 signature: sig.as_str().to_string(),
297 gas_used: TestKindReport::Invariant {
298 runs: runs.as_str().parse().unwrap(),
299 calls: calls.as_str().parse().unwrap(),
300 reverts: reverts.as_str().parse().unwrap(),
301 failed_corpus_replays: 0,
302 optimization_best_value: None,
303 },
304 })
305 }
306 })
307 })
308 })
309 .ok_or_else(|| format!("Could not extract Snapshot Entry for {s}"))
310 }
311}
312
313fn read_gas_snapshot(path: impl AsRef<Path>) -> Result<Vec<GasSnapshotEntry>> {
315 let path = path.as_ref();
316 let mut entries = Vec::new();
317 for line in io::BufReader::new(
318 fs::File::open(path)
319 .wrap_err(format!("failed to read snapshot file \"{}\"", path.display()))?,
320 )
321 .lines()
322 {
323 entries
324 .push(GasSnapshotEntry::from_str(line?.as_str()).map_err(|err| eyre::eyre!("{err}"))?);
325 }
326 Ok(entries)
327}
328
329fn write_to_gas_snapshot_file(
331 tests: &[SuiteTestResult],
332 path: impl AsRef<Path>,
333 _format: Option<Format>,
334) -> Result<()> {
335 let mut reports = tests
336 .iter()
337 .map(|test| {
338 format!("{}:{} {}", test.contract_name(), test.signature, test.result.kind.report())
339 })
340 .collect::<Vec<_>>();
341
342 reports.sort();
344
345 let content = reports.join("\n");
346 Ok(fs::write(path, content)?)
347}
348
349fn build_gas_snapshot_table(tests: &[SuiteTestResult]) -> Table {
350 let mut table = Table::new();
351 if shell::is_markdown() {
352 table.load_style(ASCII_MARKDOWN);
353 } else {
354 table.load_style(ASCII_FULL.with_rounded_corners());
355 }
356
357 table.set_header(vec![
358 Cell::new("Contract").fg(Color::Cyan),
359 Cell::new("Signature").fg(Color::Cyan),
360 Cell::new("Report").fg(Color::Cyan),
361 ]);
362
363 for test in tests {
364 let mut row = Row::new();
365 row.add_cell(Cell::new(test.contract_name()));
366 row.add_cell(Cell::new(&test.signature));
367 row.add_cell(Cell::new(test.result.kind.report()));
368 table.add_row(row);
369 }
370
371 table
372}
373
374#[derive(Clone, Debug, PartialEq, Eq)]
376pub struct GasSnapshotDiff {
377 pub signature: String,
378 pub source_gas_used: TestKindReport,
379 pub target_gas_used: TestKindReport,
380}
381
382impl GasSnapshotDiff {
383 const fn gas_change(&self) -> i128 {
388 self.source_gas_used.gas() as i128 - self.target_gas_used.gas() as i128
389 }
390
391 fn gas_diff(&self) -> f64 {
393 let target_gas = self.target_gas_used.gas();
394 if target_gas > 0 {
395 self.gas_change() as f64 / target_gas as f64
396 } else if self.source_gas_used.gas() == 0 {
397 0.0
399 } else {
400 f64::INFINITY
402 }
403 }
404}
405
406fn check(
410 tests: Vec<SuiteTestResult>,
411 snaps: Vec<GasSnapshotEntry>,
412 tolerance: Option<u32>,
413) -> bool {
414 let snaps = snaps
415 .into_iter()
416 .map(|s| ((s.contract_name, s.signature), s.gas_used))
417 .collect::<HashMap<_, _>>();
418 let mut has_diff = false;
419 for test in tests {
420 if let Some(target_gas) =
421 snaps.get(&(test.contract_name().to_string(), test.signature.clone())).cloned()
422 {
423 let source_gas = test.result.kind.report();
424 if !within_tolerance(source_gas.gas(), target_gas.gas(), tolerance) {
425 let _ = sh_eprintln!(
426 "Diff in \"{}::{}\": consumed \"{}\" gas, expected \"{}\" gas ",
427 test.contract_name(),
428 test.signature,
429 source_gas,
430 target_gas
431 );
432 has_diff = true;
433 }
434 } else {
435 let _ = sh_eprintln!(
436 "No matching snapshot entry found for \"{}::{}\" in snapshot file",
437 test.contract_name(),
438 test.signature
439 );
440 has_diff = true;
441 }
442 }
443 !has_diff
444}
445
446fn diff(
448 tests: Vec<SuiteTestResult>,
449 snaps: Vec<GasSnapshotEntry>,
450 sort_order: DiffSortOrder,
451) -> Result<()> {
452 let snaps = snaps
453 .into_iter()
454 .map(|s| ((s.contract_name, s.signature), s.gas_used))
455 .collect::<HashMap<_, _>>();
456 let mut diffs = Vec::with_capacity(tests.len());
457 let mut new_tests = Vec::new();
458
459 for test in tests {
460 if let Some(target_gas_used) =
461 snaps.get(&(test.contract_name().to_string(), test.signature.clone())).cloned()
462 {
463 diffs.push(GasSnapshotDiff {
464 source_gas_used: test.result.kind.report(),
465 signature: format!("{}::{}", test.contract_name(), test.signature),
466 target_gas_used,
467 });
468 } else {
469 new_tests.push(format!("{}::{}", test.contract_name(), test.signature));
471 }
472 }
473
474 let mut increased = 0;
475 let mut decreased = 0;
476 let mut unchanged = 0;
477 let mut overall_gas_change = 0i128;
478 let mut overall_gas_used = 0i128;
479
480 match sort_order {
482 DiffSortOrder::Percentage => {
483 diffs.sort_by(|a, b| a.gas_diff().abs().total_cmp(&b.gas_diff().abs()));
485 }
486 DiffSortOrder::PercentageDesc => {
487 diffs.sort_by(|a, b| b.gas_diff().abs().total_cmp(&a.gas_diff().abs()));
489 }
490 DiffSortOrder::Absolute => {
491 diffs.sort_by_key(|d| d.gas_change().abs());
493 }
494 DiffSortOrder::AbsoluteDesc => {
495 diffs.sort_by_key(|d| std::cmp::Reverse(d.gas_change().abs()));
497 }
498 }
499
500 for diff in &diffs {
501 let gas_change = diff.gas_change();
502 overall_gas_change += gas_change;
503 overall_gas_used += diff.target_gas_used.gas() as i128;
504 let gas_diff = diff.gas_diff();
505
506 if gas_change > 0 {
508 increased += 1;
509 } else if gas_change < 0 {
510 decreased += 1;
511 } else {
512 unchanged += 1;
513 }
514
515 let icon = if gas_change > 0 {
517 "↑".red().to_string()
518 } else if gas_change < 0 {
519 "↓".green().to_string()
520 } else {
521 "━".to_string()
522 };
523
524 sh_println!(
525 "{} {} (gas: {} → {} | {} {})",
526 icon,
527 diff.signature,
528 diff.target_gas_used.gas(),
529 diff.source_gas_used.gas(),
530 fmt_change(gas_change),
531 fmt_pct_change(gas_diff)
532 )?;
533 }
534
535 if !new_tests.is_empty() {
537 sh_eprintln!("\n{}", "New tests:".yellow())?;
538 for test in new_tests {
539 sh_eprintln!(" {} {}", "+".green(), test)?;
540 }
541 }
542
543 sh_eprintln!("\n{}", "-".repeat(80))?;
545
546 let overall_gas_diff = if overall_gas_used > 0 {
547 overall_gas_change as f64 / overall_gas_used as f64
548 } else {
549 0.0
550 };
551
552 sh_eprintln!(
553 "Total tests: {}, {} {}, {} {}, {} {}",
554 diffs.len(),
555 "↑".red().to_string(),
556 increased,
557 "↓".green().to_string(),
558 decreased,
559 "━",
560 unchanged
561 )?;
562 sh_eprintln!(
563 "Overall gas change: {} ({})",
564 fmt_change(overall_gas_change),
565 fmt_pct_change(overall_gas_diff)
566 )?;
567 Ok(())
568}
569
570fn fmt_pct_change(change: f64) -> String {
571 let change_pct = change * 100.0;
572 match change.total_cmp(&0.0) {
573 Ordering::Less => format!("{change_pct:.3}%").green().to_string(),
574 Ordering::Equal => {
575 format!("{change_pct:.3}%")
576 }
577 Ordering::Greater => format!("{change_pct:.3}%").red().to_string(),
578 }
579}
580
581fn fmt_change(change: i128) -> String {
582 match change.cmp(&0) {
583 Ordering::Less => format!("{change}").green().to_string(),
584 Ordering::Equal => change.to_string(),
585 Ordering::Greater => format!("{change}").red().to_string(),
586 }
587}
588
589fn within_tolerance(source_gas: u64, target_gas: u64, tolerance_pct: Option<u32>) -> bool {
593 if let Some(tolerance) = tolerance_pct {
594 let (hi, lo) = if source_gas > target_gas {
595 (source_gas, target_gas)
596 } else {
597 (target_gas, source_gas)
598 };
599 if hi == 0 {
600 return true;
602 }
603 let diff = (1. - (lo as f64 / hi as f64)) * 100.;
604 diff < tolerance as f64
605 } else {
606 source_gas == target_gas
607 }
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613
614 #[test]
615 fn test_tolerance() {
616 assert!(within_tolerance(100, 105, Some(5)));
617 assert!(within_tolerance(105, 100, Some(5)));
618 assert!(!within_tolerance(100, 106, Some(5)));
619 assert!(!within_tolerance(106, 100, Some(5)));
620 assert!(within_tolerance(100, 100, None));
621 assert!(within_tolerance(0, 0, Some(5)));
622 }
623
624 #[test]
625 fn can_parse_basic_gas_snapshot_entry() {
626 let s = "Test:deposit() (gas: 7222)";
627 let entry = GasSnapshotEntry::from_str(s).unwrap();
628 assert_eq!(
629 entry,
630 GasSnapshotEntry {
631 contract_name: "Test".to_string(),
632 signature: "deposit()".to_string(),
633 gas_used: TestKindReport::Unit { gas: 7222 }
634 }
635 );
636 }
637
638 #[test]
639 fn can_parse_fuzz_gas_snapshot_entry() {
640 let s = "Test:deposit() (runs: 256, μ: 100, ~:200)";
641 let entry = GasSnapshotEntry::from_str(s).unwrap();
642 assert_eq!(
643 entry,
644 GasSnapshotEntry {
645 contract_name: "Test".to_string(),
646 signature: "deposit()".to_string(),
647 gas_used: TestKindReport::Fuzz {
648 runs: 256,
649 median_gas: 200,
650 mean_gas: 100,
651 failed_corpus_replays: 0
652 }
653 }
654 );
655 }
656
657 #[test]
658 fn can_parse_invariant_gas_snapshot_entry() {
659 let s = "Test:deposit() (runs: 256, calls: 100, reverts: 200)";
660 let entry = GasSnapshotEntry::from_str(s).unwrap();
661 assert_eq!(
662 entry,
663 GasSnapshotEntry {
664 contract_name: "Test".to_string(),
665 signature: "deposit()".to_string(),
666 gas_used: TestKindReport::Invariant {
667 runs: 256,
668 calls: 100,
669 reverts: 200,
670 failed_corpus_replays: 0,
671 optimization_best_value: None,
672 }
673 }
674 );
675 }
676
677 #[test]
678 fn can_parse_invariant_gas_snapshot_entry2() {
679 let s = "ERC20Invariants:invariantBalanceSum() (runs: 256, calls: 3840, reverts: 2388)";
680 let entry = GasSnapshotEntry::from_str(s).unwrap();
681 assert_eq!(
682 entry,
683 GasSnapshotEntry {
684 contract_name: "ERC20Invariants".to_string(),
685 signature: "invariantBalanceSum()".to_string(),
686 gas_used: TestKindReport::Invariant {
687 runs: 256,
688 calls: 3840,
689 reverts: 2388,
690 failed_corpus_replays: 0,
691 optimization_best_value: None,
692 }
693 }
694 );
695 }
696}