1use std::{
7 fmt::Write as _,
8 fs, io,
9 path::{Path, PathBuf},
10};
11
12use serde_json::Value;
13
14use super::{
15 BenchmarkAggregateRow, BenchmarkAverages, BenchmarkComparisonReport, BenchmarkEventKind,
16 BenchmarkRunMetadata, BenchmarkRunReport, BenchmarkScope, BenchmarkSpan, InvalidBenchmarkSpan,
17 MalformedBenchmarkMarker, RawBenchmarkEvent, UnpairedBenchmarkMarker,
18 UnpairedBenchmarkMarkerKind,
19};
20
21const ALL_SUITES_LABEL: &str = "ALL";
22
23impl BenchmarkScope {
24 fn label(&self) -> &str {
25 match self {
26 Self::Suite(suite) => suite,
27 Self::All => ALL_SUITES_LABEL,
28 }
29 }
30}
31
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct BenchmarkRunDirectory {
34 pub path: PathBuf,
35 pub directory_name: String,
36 pub run_index: u32,
37 pub git_commit_hash: Option<String>,
38 pub git_commit_short_hash: Option<String>,
39}
40
41#[must_use]
42pub fn benchmark_run_directory_name(
43 timestamp: &str,
44 git_commit_short_hash: Option<&str>,
45 run_index: u32,
46) -> String {
47 let commit = git_commit_short_hash
48 .filter(|hash| !hash.is_empty())
49 .unwrap_or("unknown");
50 format!("{timestamp}-{commit}-{run_index:04}")
51}
52
53pub fn next_benchmark_run_directory(
60 runs_root: impl AsRef<Path>,
61 timestamp: &str,
62 git_commit_hash: Option<&str>,
63) -> io::Result<BenchmarkRunDirectory> {
64 let runs_root = runs_root.as_ref();
65 let git_commit_hash = git_commit_hash.filter(|hash| !hash.is_empty());
66 let git_commit_short_hash = git_commit_hash.map(short_commit_hash);
67 let prefix = format!(
68 "{}-{}-",
69 timestamp,
70 git_commit_short_hash.as_deref().unwrap_or("unknown")
71 );
72 let run_index = next_run_index_for_prefix(runs_root, &prefix)?;
73 let directory_name =
74 benchmark_run_directory_name(timestamp, git_commit_short_hash.as_deref(), run_index);
75
76 Ok(BenchmarkRunDirectory {
77 path: runs_root.join(&directory_name),
78 directory_name,
79 run_index,
80 git_commit_hash: git_commit_hash.map(str::to_string),
81 git_commit_short_hash,
82 })
83}
84
85pub fn find_latest_previous_run(
94 runs_root: impl AsRef<Path>,
95 current_run_directory_name: &str,
96 benchmark_command: Option<&str>,
97) -> io::Result<Option<PathBuf>> {
98 let runs_root = runs_root.as_ref();
99 let current_key = run_directory_sort_key(current_run_directory_name).ok_or_else(|| {
100 io::Error::new(
101 io::ErrorKind::InvalidInput,
102 "invalid benchmark run directory name",
103 )
104 })?;
105 let mut latest: Option<((String, String, u32), PathBuf)> = None;
106
107 let entries = match fs::read_dir(runs_root) {
108 Ok(entries) => entries,
109 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
110 Err(error) => return Err(error),
111 };
112 for entry in entries {
113 let entry = entry?;
114 if !entry.file_type()?.is_dir() {
115 continue;
116 }
117
118 let directory_name = entry.file_name().to_string_lossy().into_owned();
119 let Some((prefix, index)) = run_directory_sort_key(&directory_name) else {
120 continue;
121 };
122 if (prefix, index) >= current_key {
123 continue;
124 }
125
126 let metadata_path = entry.path().join("metadata.json");
127 let Ok(metadata) = read_benchmark_run_metadata(&metadata_path) else {
128 continue;
129 };
130
131 if let Some(command) = benchmark_command
132 && metadata.benchmark_command.as_deref() != Some(command)
133 {
134 continue;
135 }
136
137 let key = (metadata.timestamp, prefix.to_owned(), index);
138 if latest
139 .as_ref()
140 .is_none_or(|(latest_key, _)| key >= *latest_key)
141 {
142 latest = Some((key, entry.path()));
143 }
144 }
145
146 Ok(latest.map(|(_, path)| path))
147}
148
149pub fn read_benchmark_run_metadata(path: impl AsRef<Path>) -> io::Result<BenchmarkRunMetadata> {
150 let input = fs::read_to_string(path)?;
151 let value = serde_json::from_str::<Value>(&input).map_err(metadata_json_error)?;
152
153 if !value.is_object() {
154 return Err(io::Error::new(
155 io::ErrorKind::InvalidData,
156 "benchmark metadata JSON must be an object",
157 ));
158 }
159 serde_json::from_value(value).map_err(metadata_json_error)
160}
161
162pub fn write_benchmark_report_dir(
167 report: &BenchmarkRunReport,
168 path: impl AsRef<Path>,
169) -> io::Result<()> {
170 let path = path.as_ref();
171 let suite_aggregates = aggregates_csv(
172 report
173 .aggregates
174 .rows
175 .iter()
176 .filter(|row| matches!(row.scope, BenchmarkScope::Suite(_))),
177 )?;
178 let all_aggregates = aggregates_csv(
179 report
180 .aggregates
181 .rows
182 .iter()
183 .filter(|row| matches!(row.scope, BenchmarkScope::All)),
184 )?;
185 let summary = benchmark_summary_markdown(report)?;
186 fs::create_dir_all(path)?;
187
188 fs::write(
189 path.join("raw-events.csv"),
190 raw_events_csv(&report.parse.events),
191 )?;
192 fs::write(
193 path.join("malformed-markers.csv"),
194 malformed_markers_csv(&report.parse.malformed_markers),
195 )?;
196 fs::write(path.join("spans.csv"), spans_csv(&report.spans.spans))?;
197 fs::write(
198 path.join("unpaired-markers.csv"),
199 unpaired_markers_csv(&report.spans.unpaired_markers),
200 )?;
201 fs::write(
202 path.join("invalid-spans.csv"),
203 invalid_spans_csv(&report.spans.invalid_spans),
204 )?;
205 fs::write(path.join("suite-aggregates.csv"), suite_aggregates)?;
206 fs::write(path.join("all-aggregates.csv"), all_aggregates)?;
207 fs::write(
208 path.join("comparison.csv"),
209 comparison_csv(report.comparison.as_ref()),
210 )?;
211 fs::write(path.join("bench-summary.md"), summary)?;
212 fs::write(path.join("metadata.json"), metadata_json(&report.metadata))?;
213
214 Ok(())
215}
216
217fn raw_events_csv(events: &[RawBenchmarkEvent]) -> String {
218 let mut out = String::from(
219 "source_line,source,prefix,suite,label,span_label,kind,instructions,heap_bytes,memory_bytes,total_allocation\n",
220 );
221 for event in events {
222 let _ = writeln!(
223 out,
224 "{},{},{},{},{},{},{},{},{},{},{}",
225 event.source_line,
226 event.source.as_str(),
227 csv_cell(&event.prefix),
228 csv_cell(&event.suite),
229 csv_cell(&event.label),
230 csv_cell(&event.span_label),
231 kind_str(event.kind),
232 event.counters.instructions,
233 event.counters.heap_bytes,
234 event.counters.memory_bytes,
235 event.counters.total_allocation
236 );
237 }
238 out
239}
240
241fn malformed_markers_csv(markers: &[MalformedBenchmarkMarker]) -> String {
242 let mut out = String::from("source_line,source,reason,line\n");
243 for marker in markers {
244 let _ = writeln!(
245 out,
246 "{},{},{},{}",
247 marker.source_line,
248 marker.source.as_str(),
249 csv_cell(&marker.reason),
250 csv_cell(&marker.line)
251 );
252 }
253 out
254}
255
256fn spans_csv(spans: &[BenchmarkSpan]) -> String {
257 let mut out = String::from(
258 "suite,span_label,start_line,end_line,instructions_delta,heap_bytes_delta,memory_bytes_delta,total_allocation_delta\n",
259 );
260 for span in spans {
261 let _ = writeln!(
262 out,
263 "{},{},{},{},{},{},{},{}",
264 csv_cell(&span.suite),
265 csv_cell(&span.span_label),
266 span.start_line,
267 span.end_line,
268 span.delta.instructions,
269 span.delta.heap_bytes,
270 span.delta.memory_bytes,
271 span.delta.total_allocation
272 );
273 }
274 out
275}
276
277fn unpaired_markers_csv(markers: &[UnpairedBenchmarkMarker]) -> String {
278 let mut out = String::from("source_line,source,kind,suite,span_label,label\n");
279 for marker in markers {
280 let kind = match marker.kind {
281 UnpairedBenchmarkMarkerKind::Start => "start",
282 UnpairedBenchmarkMarkerKind::End => "end",
283 };
284 let _ = writeln!(
285 out,
286 "{},{},{},{},{},{}",
287 marker.event.source_line,
288 marker.event.source.as_str(),
289 kind,
290 csv_cell(&marker.event.suite),
291 csv_cell(&marker.event.span_label),
292 csv_cell(&marker.event.label)
293 );
294 }
295 out
296}
297
298fn invalid_spans_csv(spans: &[InvalidBenchmarkSpan]) -> String {
299 let mut out = String::from("start_line,end_line,suite,span_label,reason\n");
300 for span in spans {
301 let _ = writeln!(
302 out,
303 "{},{},{},{},{}",
304 span.start.source_line,
305 span.end.source_line,
306 csv_cell(&span.start.suite),
307 csv_cell(&span.start.span_label),
308 csv_cell(&span.reason)
309 );
310 }
311 out
312}
313
314fn aggregates_csv<'a>(rows: impl Iterator<Item = &'a BenchmarkAggregateRow>) -> io::Result<String> {
315 let mut out = String::from(
316 "suite,span_label,runs,instructions_total,instructions_mean,heap_bytes_total,heap_bytes_mean,memory_bytes_total,memory_bytes_mean,total_allocation_total,total_allocation_mean\n",
317 );
318 for row in rows {
319 let average = row.average(1).map_err(io::Error::other)?;
320 let _ = writeln!(
321 out,
322 "{},{},{},{},{},{},{},{},{},{},{}",
323 csv_cell(row.scope.label()),
324 csv_cell(&row.span_label),
325 row.runs,
326 row.total.instructions,
327 average.instructions,
328 row.total.heap_bytes,
329 average.heap_bytes,
330 row.total.memory_bytes,
331 average.memory_bytes,
332 row.total.total_allocation,
333 average.total_allocation
334 );
335 }
336 Ok(out)
337}
338
339fn comparison_csv(comparison: Option<&BenchmarkComparisonReport>) -> String {
340 let mut out = String::from(
341 "scope,suite,span_label,current_runs,previous_runs,current_mean_scale,previous_mean_scale,current_instructions_mean,previous_instructions_mean,current_heap_bytes_mean,previous_heap_bytes_mean,current_memory_bytes_mean,previous_memory_bytes_mean,current_total_allocation_mean,previous_total_allocation_mean\n",
342 );
343 if let Some(comparison) = comparison {
344 for row in &comparison.rows {
345 let current = row.current_average;
346 let previous = row.previous_average;
347 let value = |mean: Option<BenchmarkAverages>, field: fn(BenchmarkAverages) -> u128| {
348 mean.map_or_else(String::new, |mean| field(mean).to_string())
349 };
350 let _ = writeln!(
351 out,
352 "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}",
353 if matches!(row.scope, BenchmarkScope::All) {
354 "all"
355 } else {
356 "suite"
357 },
358 csv_cell(row.scope.label()),
359 csv_cell(&row.span_label),
360 optional_u64_cell(row.current_runs),
361 optional_u64_cell(row.previous_runs),
362 current.map_or_else(String::new, |mean| mean.scale.to_string()),
363 previous.map_or_else(String::new, |mean| mean.scale.to_string()),
364 value(current, |m| m.instructions),
365 value(previous, |m| m.instructions),
366 value(current, |m| m.heap_bytes),
367 value(previous, |m| m.heap_bytes),
368 value(current, |m| m.memory_bytes),
369 value(previous, |m| m.memory_bytes),
370 value(current, |m| m.total_allocation),
371 value(previous, |m| m.total_allocation)
372 );
373 }
374 }
375 out
376}
377
378fn benchmark_summary_markdown(report: &BenchmarkRunReport) -> io::Result<String> {
379 let mut out = String::from(
380 "# Benchmark Summary\n\nMeans are floor-rounded in original counter units. Comparison CSV retains current and previous scaled means.\n\n| Benchmark | Runs | Instructions Mean | Heap Bytes Mean | Memory Bytes Mean | Allocation Mean |\n| --- | ---: | ---: | ---: | ---: | ---: |\n",
381 );
382 for row in report
383 .aggregates
384 .rows
385 .iter()
386 .filter(|row| matches!(row.scope, BenchmarkScope::Suite(_)))
387 {
388 let average = row.average(1).map_err(io::Error::other)?;
389 let _ = writeln!(
390 out,
391 "| {} | {} | {} | {} | {} | {} |",
392 markdown_cell(&row.span_label),
393 row.runs,
394 average.instructions,
395 average.heap_bytes,
396 average.memory_bytes,
397 average.total_allocation
398 );
399 }
400 Ok(out)
401}
402
403fn metadata_json(metadata: &BenchmarkRunMetadata) -> String {
404 let mut output = serde_json::to_string_pretty(metadata).expect("metadata JSON must serialize");
405 output.push('\n');
406 output
407}
408
409fn next_run_index_for_prefix(runs_root: &Path, prefix: &str) -> io::Result<u32> {
410 let entries = match fs::read_dir(runs_root) {
411 Ok(entries) => entries,
412 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(1),
413 Err(error) => return Err(error),
414 };
415 let mut max_index = 0;
416 for entry in entries {
417 let entry = entry?;
418 if !entry.file_type()?.is_dir() {
419 continue;
420 }
421
422 if let Some(index) = entry
423 .file_name()
424 .to_str()
425 .and_then(|name| name.strip_prefix(prefix))
426 .and_then(parse_run_index)
427 {
428 max_index = max_index.max(index);
429 }
430 }
431
432 max_index
433 .checked_add(1)
434 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "benchmark run index exhausted"))
435}
436
437fn parse_run_index(index: &str) -> Option<u32> {
438 if index.len() >= 4 && index.chars().all(|char| char.is_ascii_digit()) {
439 index.parse().ok()
440 } else {
441 None
442 }
443}
444
445fn run_directory_sort_key(name: &str) -> Option<(&str, u32)> {
446 let (prefix, index) = name.rsplit_once('-')?;
447 Some((prefix, parse_run_index(index)?))
448}
449
450fn short_commit_hash(hash: &str) -> String {
451 hash.chars().take(7).collect()
452}
453
454fn metadata_json_error(err: serde_json::Error) -> io::Error {
455 io::Error::new(
456 io::ErrorKind::InvalidData,
457 format!("invalid benchmark metadata JSON: {err}"),
458 )
459}
460
461const fn kind_str(kind: BenchmarkEventKind) -> &'static str {
462 match kind {
463 BenchmarkEventKind::Start => "start",
464 BenchmarkEventKind::End => "end",
465 }
466}
467
468fn csv_cell(value: &str) -> String {
469 if value.contains([',', '"', '\n', '\r']) {
470 format!("\"{}\"", value.replace('"', "\"\""))
471 } else {
472 value.to_string()
473 }
474}
475
476fn optional_u64_cell(value: Option<u64>) -> String {
477 value.map_or_else(String::new, |value| value.to_string())
478}
479
480fn markdown_cell(value: &str) -> String {
481 value.replace('|', "\\|")
482}