1use crate::RepoConfig;
2use eyre::Result;
3use serde::{Deserialize, Serialize};
4use std::{collections::HashMap, process::Command, thread};
5
6#[derive(Debug, Deserialize, Serialize)]
8pub struct HyperfineResult {
9 pub command: String,
10 pub mean: f64,
11 pub stddev: Option<f64>,
12 pub median: f64,
13 pub user: f64,
14 pub system: f64,
15 pub min: f64,
16 pub max: f64,
17 pub times: Vec<f64>,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub exit_codes: Option<Vec<i32>>,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub parameters: Option<HashMap<String, serde_json::Value>>,
22}
23
24#[derive(Debug, Deserialize, Serialize)]
26pub struct HyperfineOutput {
27 pub results: Vec<HyperfineResult>,
28}
29
30#[derive(Debug, Default)]
32pub struct BenchmarkResults {
33 pub data: HashMap<String, HashMap<String, HashMap<String, HyperfineResult>>>,
35 pub baseline_version: Option<String>,
37 pub version_details: HashMap<String, String>,
39}
40
41impl BenchmarkResults {
42 pub fn new() -> Self {
43 Self::default()
44 }
45
46 pub fn set_baseline_version(&mut self, version: String) {
47 self.baseline_version = Some(version);
48 }
49
50 pub fn add_result(
51 &mut self,
52 benchmark: &str,
53 version: &str,
54 repo: &str,
55 result: HyperfineResult,
56 ) {
57 self.data
58 .entry(benchmark.to_string())
59 .or_default()
60 .entry(version.to_string())
61 .or_default()
62 .insert(repo.to_string(), result);
63 }
64
65 pub fn add_version_details(&mut self, version: &str, details: String) {
66 self.version_details.insert(version.to_string(), details);
67 }
68
69 pub fn generate_json_summary(&self, versions: &[String]) -> HashMap<String, f64> {
73 let mut summary = HashMap::new();
74 for (benchmark_name, version_data) in &self.data {
75 for version in versions {
76 if let Some(repo_data) = version_data.get(version) {
77 for (repo_name, result) in repo_data {
78 let key = format!("{benchmark_name}/{repo_name}");
79 let rounded = (result.mean * 10_000.0).round() / 10_000.0;
80 summary.insert(key, rounded);
81 }
82 }
83 }
84 }
85 summary
86 }
87
88 pub fn generate_markdown(&self, versions: &[String], repos: &[RepoConfig]) -> String {
89 let mut output = String::new();
90
91 output.push_str("# Foundry Benchmark Results\n\n");
93 output.push_str(&format!(
94 "**Date**: {}\n\n",
95 chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
96 ));
97
98 output.push_str("## Summary\n\n");
100 let mut repos_with_results = std::collections::HashSet::new();
102 for version_data in self.data.values() {
103 for repo_data in version_data.values() {
104 for repo_name in repo_data.keys() {
105 repos_with_results.insert(repo_name.clone());
106 }
107 }
108 }
109 output.push_str(&format!(
110 "Benchmarked {} Foundry versions across {} repositories.\n\n",
111 versions.len(),
112 repos_with_results.len()
113 ));
114
115 output.push_str("### Repositories Tested\n\n");
117 for (i, repo) in repos.iter().enumerate() {
118 output.push_str(&format!(
119 "{}. [{}/{}](https://github.com/{}/{})\n",
120 i + 1,
121 repo.org,
122 repo.repo,
123 repo.org,
124 repo.repo
125 ));
126 }
127 output.push('\n');
128
129 output.push_str("### Foundry Versions\n\n");
131 for version in versions {
132 if let Some(details) = self.version_details.get(version) {
133 output.push_str(&format!("- **{version}**: {}\n", details.trim()));
134 } else {
135 output.push_str(&format!("- {version}\n"));
136 }
137 }
138 output.push('\n');
139
140 for (benchmark_name, version_data) in &self.data {
142 output.push_str(&self.generate_benchmark_table(
143 benchmark_name,
144 version_data,
145 versions,
146 repos,
147 ));
148 }
149
150 output.push_str("## System Information\n\n");
152 output.push_str(&format!("- **OS**: {}\n", std::env::consts::OS));
153 output.push_str(&format!(
154 "- **CPU**: {}\n",
155 thread::available_parallelism().map_or(1, |n| n.get())
156 ));
157 output.push_str(&format!(
158 "- **Rustc**: {}\n",
159 get_rustc_version().unwrap_or_else(|_| "unknown".to_string())
160 ));
161
162 output
163 }
164
165 fn generate_benchmark_table(
169 &self,
170 benchmark_name: &str,
171 version_data: &HashMap<String, HashMap<String, HyperfineResult>>,
172 versions: &[String],
173 repos: &[RepoConfig],
174 ) -> String {
175 let mut output = String::new();
176
177 output.push_str(&format!("## {}\n\n", format_benchmark_name(benchmark_name)));
179
180 output.push_str("| Repository |");
182 for version in versions {
183 output.push_str(&format!(" {version} |"));
184 }
185 output.push('\n');
186
187 output.push_str("|------------|");
189 for _ in versions {
190 output.push_str("----------|");
191 }
192 output.push('\n');
193
194 output.push_str(&generate_table_rows(version_data, versions, repos));
196 output.push('\n');
197
198 output
199 }
200}
201
202fn generate_table_rows(
207 version_data: &HashMap<String, HashMap<String, HyperfineResult>>,
208 versions: &[String],
209 repos: &[RepoConfig],
210) -> String {
211 let mut output = String::new();
212
213 for repo in repos {
214 output.push_str(&format!("| {} |", repo.name));
215
216 for version in versions {
217 let cell_content = get_benchmark_cell_content(version_data, version, &repo.name);
218 output.push_str(&format!(" {cell_content} |"));
219 }
220
221 output.push('\n');
222 }
223
224 output
225}
226
227fn get_benchmark_cell_content(
234 version_data: &HashMap<String, HashMap<String, HyperfineResult>>,
235 version: &str,
236 repo_name: &str,
237) -> String {
238 if let Some(repo_data) = version_data.get(version) &&
240 let Some(result) = repo_data.get(repo_name)
242 {
243 return format_duration_seconds(result.mean);
244 }
245
246 "N/A".to_string()
247}
248
249pub fn format_benchmark_name(name: &str) -> String {
250 match name {
251 "forge_test" => "Forge Test",
252 "forge_build_no_cache" => "Forge Build (No Cache)",
253 "forge_build_with_cache" => "Forge Build (With Cache)",
254 "forge_fuzz_test" => "Forge Fuzz Test",
255 "forge_coverage" => "Forge Coverage",
256 "forge_isolate_test" => "Forge Test (Isolated)",
257 _ => name,
258 }
259 .to_string()
260}
261
262pub fn format_duration_seconds(seconds: f64) -> String {
263 if seconds < 0.001 {
264 format!("{:.2} ms", seconds * 1000.0)
265 } else if seconds < 1.0 {
266 format!("{seconds:.3} s")
267 } else if seconds < 60.0 {
268 format!("{seconds:.2} s")
269 } else {
270 let minutes = (seconds / 60.0).floor();
271 let remaining_seconds = seconds % 60.0;
272 format!("{minutes:.0}m {remaining_seconds:.1}s")
273 }
274}
275
276pub fn get_rustc_version() -> Result<String> {
277 let output = Command::new("rustc").arg("--version").output()?;
278
279 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
280}