Skip to main content

krabka_parse_benches/
lib.rs

1//! Parses Criterion benchmark output in bencher format into structured JSON
2//! summaries, and judges repeated reference/candidate measurements against
3//! variance-calibrated budgets.
4//!
5//! Two lanes share one set of statistics. The criterion lane compares
6//! [`BenchmarkSummary`] files with [`compare_summaries`]. The broker
7//! performance lane judges a [`record::PerformanceRecord`] against a
8//! [`contract::Contract`] with [`budget::evaluate`]. Both take medians over
9//! repeated runs and allow the larger of a fixed floor and a multiple of the
10//! relative median absolute deviation: [`calibrated_tolerance`].
11//!
12//! Each criterion summary also names the bench executables its pass ran, as
13//! [`executables`] verified them, so a verdict says which binaries produced
14//! its numbers.
15//!
16//! Note: If a shared `krabka-tools` repository is established across the
17//! `krabka-io` organization, this crate can be migrated there as a common
18//! benchmark utility for `krabka-broker`, `krabka-protocol`, and `krabka-client-rs`.
19
20pub mod budget;
21pub mod contract;
22pub mod executables;
23mod num;
24pub mod record;
25pub mod render;
26
27use {
28    budget::evaluate,
29    clap::{Parser, Subcommand},
30    contract::{BudgetError, Contract, contract_digest},
31    executables::{BenchExecutable, ExecutableManifest, manifest_from_cargo_messages, verify_pass},
32    record::{PerformanceRecord, assemble},
33    regex::Regex,
34    render::render_markdown,
35    serde::{Deserialize, Serialize},
36    std::{
37        collections::BTreeMap,
38        env,
39        fs::{self, File},
40        io::{self, BufRead, BufReader, Write},
41        path::{Path, PathBuf},
42        sync::LazyLock,
43        time::SystemTime,
44    },
45    thiserror::Error,
46    time::format_description::well_known::Rfc3339,
47};
48
49static BENCHER_REGEX: LazyLock<Result<Regex, regex::Error>> = LazyLock::new(|| {
50    Regex::new(
51        r"test\s+([\w\/\-\.]+)\s+\.\.\.\s+bench:\s+([\d,]+)\s+ns\/iter\s+\(\+\/-\s+([\d,]+)\)",
52    )
53});
54
55/// Errors encountered while parsing benchmark output.
56#[derive(Debug, Error)]
57#[non_exhaustive]
58pub enum ParseBenchesError {
59    /// Benchmark results directory does not exist.
60    #[error("directory '{0}' does not exist")]
61    DirectoryNotFound(PathBuf),
62
63    /// No `.txt` output files were found in the results directory.
64    #[error("no benchmark output files (*.txt) found in '{0}'")]
65    NoTxtFiles(PathBuf),
66
67    /// Output files were found, but no benchmark metrics could be parsed.
68    #[error("parsed 0 benchmark metrics from '{0}'")]
69    NoMetricsParsed(PathBuf),
70
71    /// A duplicate benchmark identifier was found.
72    #[error("duplicate benchmark metric '{name}' found in '{file}'")]
73    DuplicateBenchmark {
74        /// Name of the duplicated benchmark identifier.
75        name: String,
76        /// Path of the file containing the collision.
77        file: PathBuf,
78    },
79
80    /// Failed to parse a numeric value from benchmark output.
81    #[error("failed to parse numeric benchmark value: {0}")]
82    InvalidNumber(String),
83
84    /// The bencher line pattern did not compile.
85    #[error("bencher line pattern: {0}")]
86    Pattern(#[source] regex::Error),
87
88    /// Underlying I/O error.
89    #[error("I/O error: {0}")]
90    Io(#[from] io::Error),
91
92    /// Underlying JSON serialization error.
93    #[error("JSON error: {0}")]
94    Json(#[from] serde_json::Error),
95
96    /// Fewer than three repeated summaries were supplied for one side.
97    #[error("{side} needs at least 3 repeated benchmark summaries, got {count}")]
98    TooFewSamples {
99        /// The comparison side.
100        side: &'static str,
101        /// Number of supplied summaries.
102        count: usize,
103    },
104
105    /// Reference and candidate summaries did not contain the same benchmarks.
106    #[error("reference and candidate benchmark sets differ")]
107    BenchmarkSetMismatch,
108
109    /// A sample was non-finite or non-positive without every sample being zero.
110    #[error("invalid sample for benchmark '{0}'")]
111    InvalidSample(String),
112
113    /// A supplied summary had no benchmark metrics.
114    #[error("benchmark summaries contain no metrics")]
115    EmptyBenchmarkSet,
116
117    /// The configured tolerance was negative, NaN, or infinite.
118    #[error("minimum tolerance must be a finite non-negative ratio")]
119    InvalidTolerance,
120
121    /// Cargo's JSON messages named no bench executable.
122    #[error("cargo built no bench executable")]
123    NoBenchExecutables,
124
125    /// A pass's Cargo log named no executable that it ran.
126    #[error("the cargo log names no bench executable that ran")]
127    NoExecutableRan,
128
129    /// An executable manifest was built from a different commit than the pass.
130    #[error("executables were built from {manifest} but the pass is labelled {pass}")]
131    CommitMismatch {
132        /// The commit the manifest records.
133        manifest: String,
134        /// The commit the pass was labelled with.
135        pass: String,
136    },
137
138    /// A pass ran an executable that its side's manifest does not list.
139    #[error("pass ran '{0}', which its side did not prebuild")]
140    UnrecordedExecutable(PathBuf),
141
142    /// A prebuilt executable's bytes changed after the manifest recorded them.
143    #[error("executable '{0}' changed after it was prebuilt")]
144    ExecutableChanged(PathBuf),
145
146    /// A side's summaries name no executable.
147    #[error("{0} summaries do not record which executables ran")]
148    MissingExecutableIdentity(&'static str),
149
150    /// A side's passes ran different executables.
151    #[error("{0} passes ran different executables")]
152    ExecutableDrift(&'static str),
153
154    /// Reference and candidate ran the same executable file.
155    #[error(
156        "reference and candidate both ran '{0}'; they must build into separate target directories"
157    )]
158    SharedExecutable(PathBuf),
159
160    /// A broker performance lane command failed.
161    #[error(transparent)]
162    Budget(#[from] BudgetError),
163}
164
165/// A single benchmark measurement in nanoseconds per iteration.
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167pub struct BenchmarkMetric {
168    /// Mean nanoseconds per iteration.
169    pub ns_per_iter: f64,
170    /// Variance in nanoseconds per iteration (+/- bound).
171    pub variance_ns: f64,
172}
173
174/// A benchmark measurement together with the name of the benchmark it
175/// belongs to, as read from one line of bencher output.
176#[derive(Debug, Clone, PartialEq)]
177pub struct NamedMetric {
178    /// Canonical benchmark name.
179    pub name: String,
180    /// The measurement of that benchmark.
181    pub metric: BenchmarkMetric,
182}
183
184/// Aggregated benchmark summary document.
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
186pub struct BenchmarkSummary {
187    /// Benchmark suite name (e.g. `krabka-broker`).
188    pub suite: String,
189    /// Git commit SHA of the run.
190    pub commit: String,
191    /// ISO8601/RFC3339 UTC timestamp when the summary was generated.
192    pub timestamp: String,
193    /// Map of canonical benchmark names to metrics, sorted alphabetically.
194    pub benchmarks: BTreeMap<String, BenchmarkMetric>,
195    /// The bench executables this pass ran; empty when none were recorded.
196    #[serde(default)]
197    pub executables: Vec<BenchExecutable>,
198}
199
200/// One benchmark's variance-calibrated reference/candidate decision.
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202pub struct BenchmarkComparison {
203    /// Median reference time in nanoseconds.
204    pub reference_median_ns: f64,
205    /// Median candidate time in nanoseconds.
206    pub candidate_median_ns: f64,
207    /// Candidate/reference ratio.
208    pub ratio: f64,
209    /// Allowed relative slowdown, derived from repeated-run MAD with a floor.
210    pub tolerance: f64,
211    /// Whether this benchmark stayed within its tolerance, or its candidate
212    /// passes overlapped the reference passes.
213    pub passed: bool,
214}
215
216/// Machine-readable verdict over repeated same-host benchmark runs.
217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
218pub struct BenchmarkVerdict {
219    /// Exact commits represented by the reference samples.
220    pub reference_commits: Vec<String>,
221    /// Exact commits represented by the candidate samples.
222    pub candidate_commits: Vec<String>,
223    /// The executables every reference pass ran.
224    pub reference_executables: Vec<BenchExecutable>,
225    /// The executables every candidate pass ran.
226    pub candidate_executables: Vec<BenchExecutable>,
227    /// Per-benchmark decisions.
228    pub benchmarks: BTreeMap<String, BenchmarkComparison>,
229    /// True only when every benchmark passed.
230    pub passed: bool,
231}
232
233/// The median of `values`; an even count averages the middle two. An empty
234/// `values` has no median, and gives NaN.
235#[must_use]
236pub fn median(mut values: Vec<f64>) -> f64 {
237    values.sort_by(f64::total_cmp);
238    let middle = values.len() / 2;
239    let lower = middle.checked_sub(1).and_then(|lower| values.get(lower));
240    match (lower, values.get(middle)) {
241        (Some(&lower), Some(&upper)) if values.len().is_multiple_of(2) => {
242            f64::midpoint(lower, upper)
243        }
244        (_, Some(&upper)) => upper,
245        (_, None) => f64::NAN,
246    }
247}
248
249/// The median absolute deviation of `values` around `center`, as a ratio of
250/// `center`.
251#[must_use]
252pub fn relative_mad(values: &[f64], center: f64) -> f64 {
253    median(values.iter().map(|value| (value - center).abs()).collect()) / center
254}
255
256/// The relative change a comparison allows: `multiplier` times the larger of
257/// the two sides' spreads, but never less than `floor`.
258#[must_use]
259pub fn calibrated_tolerance(
260    floor: f64,
261    multiplier: f64,
262    reference_spread: f64,
263    candidate_spread: f64,
264) -> f64 {
265    floor.max(multiplier * reference_spread.max(candidate_spread))
266}
267
268/// The executables every summary of one side ran.
269fn side_executables(
270    side: &'static str,
271    summaries: &[BenchmarkSummary],
272) -> Result<Vec<BenchExecutable>, ParseBenchesError> {
273    let Some(first) = summaries.first().map(|summary| &summary.executables) else {
274        return Err(ParseBenchesError::TooFewSamples { side, count: 0 });
275    };
276    if first.is_empty() {
277        return Err(ParseBenchesError::MissingExecutableIdentity(side));
278    }
279    if summaries
280        .iter()
281        .any(|summary| summary.executables != *first)
282    {
283        return Err(ParseBenchesError::ExecutableDrift(side));
284    }
285    Ok(first.clone())
286}
287
288/// Compare at least three repeated reference and candidate summaries.
289///
290/// Every summary must name the executables its pass ran. Each side's passes
291/// must all have run the same executables, and no executable path may appear
292/// on both sides: a shared path means the two revisions were measured with
293/// the same binary.
294///
295/// The tolerance is three times the larger side's median absolute deviation,
296/// with `minimum_tolerance` as a floor. A benchmark fails only when its median
297/// ratio exceeds that tolerance and every candidate pass is also slower than
298/// every reference pass: with a handful of passes per side, a median shift
299/// inside the two sides' overlapping ranges is run-to-run noise, not a
300/// regression. Bilateral all-zero samples are tied
301/// below bencher's integer-nanosecond resolution. Other missing, non-finite, or
302/// non-positive samples are errors and therefore cannot silently pass.
303///
304/// # Errors
305///
306/// Returns [`ParseBenchesError`] for too few samples, mismatched benchmark
307/// sets, missing, drifting or shared executables, or invalid measurements.
308pub fn compare_summaries(
309    reference: &[BenchmarkSummary],
310    candidate: &[BenchmarkSummary],
311    minimum_tolerance: f64,
312) -> Result<BenchmarkVerdict, ParseBenchesError> {
313    if !minimum_tolerance.is_finite() || minimum_tolerance < 0.0 {
314        return Err(ParseBenchesError::InvalidTolerance);
315    }
316    for (side, summaries) in [("reference", reference), ("candidate", candidate)] {
317        if summaries.len() < 3 {
318            return Err(ParseBenchesError::TooFewSamples {
319                side,
320                count: summaries.len(),
321            });
322        }
323    }
324    let reference_names: Vec<_> = reference
325        .first()
326        .ok_or(ParseBenchesError::TooFewSamples {
327            side: "reference",
328            count: 0,
329        })?
330        .benchmarks
331        .keys()
332        .collect();
333    if reference_names.is_empty() {
334        return Err(ParseBenchesError::EmptyBenchmarkSet);
335    }
336    if reference.iter().any(|summary| {
337        summary
338            .benchmarks
339            .keys()
340            .ne(reference_names.iter().copied())
341    }) || candidate.iter().any(|summary| {
342        summary
343            .benchmarks
344            .keys()
345            .ne(reference_names.iter().copied())
346    }) {
347        return Err(ParseBenchesError::BenchmarkSetMismatch);
348    }
349    let reference_executables = side_executables("reference", reference)?;
350    let candidate_executables = side_executables("candidate", candidate)?;
351    if let Some(shared) = reference_executables.iter().find(|reference| {
352        candidate_executables
353            .iter()
354            .any(|candidate| candidate.path == reference.path)
355    }) {
356        return Err(ParseBenchesError::SharedExecutable(shared.path.clone()));
357    }
358
359    let mut benchmarks = BTreeMap::new();
360    for name in reference_names {
361        // Every summary holds exactly the reference names, checked above.
362        let side_values = |summaries: &[BenchmarkSummary]| {
363            summaries
364                .iter()
365                .map(|summary| {
366                    summary
367                        .benchmarks
368                        .get(name)
369                        .map(|metric| metric.ns_per_iter)
370                })
371                .collect::<Option<Vec<_>>>()
372                .ok_or(ParseBenchesError::BenchmarkSetMismatch)
373        };
374        let reference_values = side_values(reference)?;
375        let candidate_values = side_values(candidate)?;
376        let mut values = reference_values.iter().chain(&candidate_values);
377        if values.clone().all(|value| *value == 0.0) {
378            benchmarks.insert(name.clone(), BenchmarkComparison {
379                reference_median_ns: 0.0,
380                candidate_median_ns: 0.0,
381                ratio: 1.0,
382                tolerance: minimum_tolerance,
383                passed: true,
384            });
385            continue;
386        }
387        if values.any(|value| !value.is_finite() || *value <= 0.0) {
388            return Err(ParseBenchesError::InvalidSample(name.clone()));
389        }
390        let reference_median_ns = median(reference_values.clone());
391        let candidate_median_ns = median(candidate_values.clone());
392        let tolerance = calibrated_tolerance(
393            minimum_tolerance,
394            3.0,
395            relative_mad(&reference_values, reference_median_ns),
396            relative_mad(&candidate_values, candidate_median_ns),
397        );
398        let ratio = candidate_median_ns / reference_median_ns;
399        let slowest_reference = reference_values.iter().copied().fold(f64::MIN, f64::max);
400        let fastest_candidate = candidate_values.iter().copied().fold(f64::MAX, f64::min);
401        benchmarks.insert(name.clone(), BenchmarkComparison {
402            reference_median_ns,
403            candidate_median_ns,
404            ratio,
405            tolerance,
406            passed: ratio <= 1.0 + tolerance || fastest_candidate <= slowest_reference,
407        });
408    }
409    let passed = benchmarks.values().all(|benchmark| benchmark.passed);
410    Ok(BenchmarkVerdict {
411        reference_commits: reference
412            .iter()
413            .map(|summary| summary.commit.clone())
414            .collect(),
415        candidate_commits: candidate
416            .iter()
417            .map(|summary| summary.commit.clone())
418            .collect(),
419        reference_executables,
420        candidate_executables,
421        benchmarks,
422        passed,
423    })
424}
425
426/// Read every JSON summary in `directory`, sorted by filename.
427///
428/// # Errors
429///
430/// Returns [`ParseBenchesError`] when the directory is missing, contains fewer
431/// than three summaries, or a summary is unreadable or invalid JSON.
432pub fn read_summaries(
433    directory: &Path,
434    side: &'static str,
435) -> Result<Vec<BenchmarkSummary>, ParseBenchesError> {
436    if !directory.is_dir() {
437        return Err(ParseBenchesError::DirectoryNotFound(
438            directory.to_path_buf(),
439        ));
440    }
441    let mut paths = Vec::new();
442    for entry in fs::read_dir(directory)? {
443        let path = entry?.path();
444        if path.extension().and_then(|extension| extension.to_str()) == Some("json") {
445            paths.push(path);
446        }
447    }
448    paths.sort();
449    if paths.len() < 3 {
450        return Err(ParseBenchesError::TooFewSamples {
451            side,
452            count: paths.len(),
453        });
454    }
455    paths
456        .into_iter()
457        .map(|path| Ok(serde_json::from_reader(File::open(path)?)?))
458        .collect()
459}
460
461/// CLI configuration arguments for parsing benchmarks.
462#[derive(Parser, Debug, Clone)]
463#[command(
464    name = "krabka-parse-benches",
465    about = "Parses Criterion benchmark output in bencher format into a structured JSON summary"
466)]
467pub struct Args {
468    /// Directory containing benchmark `.txt` output files.
469    #[arg(long, default_value = "bench-results")]
470    pub results_dir: PathBuf,
471
472    /// Output JSON summary file path (defaults to `<results_dir>/broker-benchmarks.json`).
473    #[arg(long)]
474    pub output: Option<PathBuf>,
475
476    /// Suite identifier for the benchmark summary.
477    #[arg(long, default_value = "krabka-broker")]
478    pub suite: String,
479
480    /// Git commit SHA (defaults to `GITHUB_SHA` env var or `"unknown"`).
481    #[arg(long)]
482    pub commit: Option<String>,
483
484    /// Directory of repeated reference summary JSON files to compare.
485    #[arg(long, requires = "candidate_dir")]
486    pub reference_dir: Option<PathBuf>,
487
488    /// Directory of repeated candidate summary JSON files to compare.
489    #[arg(long, requires = "reference_dir")]
490    pub candidate_dir: Option<PathBuf>,
491
492    /// The executable manifest this pass's side was prebuilt into. With it,
493    /// the summary records which executables the pass ran, and parsing fails
494    /// unless they are the manifest's, unchanged.
495    #[arg(long, requires = "cargo_log")]
496    pub executables: Option<PathBuf>,
497
498    /// Cargo's standard error from this pass, whose `Running` lines name the
499    /// executables it ran.
500    #[arg(long, requires = "executables")]
501    pub cargo_log: Option<PathBuf>,
502
503    /// The directory Cargo ran in, against which relative `Running` paths
504    /// resolve (defaults to the current directory).
505    #[arg(long, requires = "executables")]
506    pub source_dir: Option<PathBuf>,
507
508    /// Minimum allowed slowdown percentage for comparison mode.
509    #[arg(long, default_value_t = 3.0)]
510    pub minimum_tolerance_percent: f64,
511
512    /// The broker performance lane; without one, the criterion lane runs.
513    #[command(subcommand)]
514    pub command: Option<Command>,
515}
516
517/// The commands besides parsing and comparing criterion output.
518#[derive(Subcommand, Debug, Clone)]
519pub enum Command {
520    /// Record the bench executables `cargo bench --no-run
521    /// --message-format=json` built, with their digests.
522    Executables {
523        /// Cargo's JSON messages.
524        cargo_messages: PathBuf,
525        /// The commit the executables were built from.
526        #[arg(long)]
527        commit: String,
528        /// Where to write the manifest JSON.
529        #[arg(long)]
530        output: PathBuf,
531    },
532    /// Validate a regression contract and print its digest.
533    CheckContract {
534        /// The contract JSON.
535        contract: PathBuf,
536    },
537    /// Assemble a `qualify.sh budget` artifact directory into a record.
538    Assemble {
539        /// The artifact directory.
540        artifact_dir: PathBuf,
541        /// Where to write the record JSON.
542        #[arg(long)]
543        output: PathBuf,
544    },
545    /// Judge a record against its contract; a rejection exits 1.
546    Evaluate {
547        /// The record JSON.
548        record: PathBuf,
549        /// The contract the record was measured under.
550        #[arg(long)]
551        contract: PathBuf,
552        /// Where to write the verdict JSON.
553        #[arg(long)]
554        output: PathBuf,
555        /// Where to write the published Markdown.
556        #[arg(long)]
557        markdown: Option<PathBuf>,
558    },
559}
560
561/// Parses a single line of Criterion bencher output.
562///
563/// Matches patterns of the shape:
564/// `test log/append/100rec_1024B ... bench: 95,979 ns/iter (+/- 1,377,729)`
565///
566/// # Errors
567///
568/// Returns [`ParseBenchesError::InvalidNumber`] if matched numeric fields cannot
569/// be parsed into floats.
570pub fn parse_bencher_line(line: &str) -> Result<Option<NamedMetric>, ParseBenchesError> {
571    let regex = BENCHER_REGEX
572        .as_ref()
573        .map_err(|error| ParseBenchesError::Pattern(error.clone()))?;
574    let Some(caps) = regex.captures(line) else {
575        return Ok(None);
576    };
577
578    let name = caps[1].to_string();
579    let ns_raw = caps[2].replace(',', "");
580    let variance_raw = caps[3].replace(',', "");
581
582    let ns_per_iter: f64 = ns_raw
583        .parse()
584        .map_err(|_| ParseBenchesError::InvalidNumber(caps[2].to_string()))?;
585    let variance_ns: f64 = variance_raw
586        .parse()
587        .map_err(|_| ParseBenchesError::InvalidNumber(caps[3].to_string()))?;
588
589    Ok(Some(NamedMetric {
590        name,
591        metric: BenchmarkMetric {
592            ns_per_iter,
593            variance_ns,
594        },
595    }))
596}
597
598/// Parses all benchmark metrics from a `.txt` file into the target map.
599///
600/// # Errors
601///
602/// Returns an error if the file cannot be read, numeric parsing fails, or a
603/// duplicate benchmark name is encountered.
604pub fn parse_bencher_file(
605    path: &Path,
606    benchmarks: &mut BTreeMap<String, BenchmarkMetric>,
607) -> Result<(), ParseBenchesError> {
608    let file = File::open(path)?;
609    let reader = BufReader::new(file);
610
611    for line in reader.lines() {
612        let line = line?;
613        if let Some(NamedMetric { name, metric }) = parse_bencher_line(&line)?
614            && benchmarks.insert(name.clone(), metric).is_some()
615        {
616            return Err(ParseBenchesError::DuplicateBenchmark {
617                name,
618                file: path.to_path_buf(),
619            });
620        }
621    }
622
623    Ok(())
624}
625
626/// Scans the given directory for all `*.txt` files and parses their benchmark metrics.
627///
628/// # Errors
629///
630/// Returns an error if:
631/// - The directory does not exist ([`ParseBenchesError::DirectoryNotFound`]).
632/// - No `*.txt` files are found ([`ParseBenchesError::NoTxtFiles`]).
633/// - No metrics were parsed from any found files ([`ParseBenchesError::NoMetricsParsed`]).
634/// - Duplicate benchmark metrics are detected across files.
635pub fn parse_benchmark_dir(
636    results_dir: &Path,
637) -> Result<BTreeMap<String, BenchmarkMetric>, ParseBenchesError> {
638    if !results_dir.exists() || !results_dir.is_dir() {
639        return Err(ParseBenchesError::DirectoryNotFound(
640            results_dir.to_path_buf(),
641        ));
642    }
643
644    let mut txt_files = Vec::new();
645    for entry in fs::read_dir(results_dir)? {
646        let entry = entry?;
647        let path = entry.path();
648        if path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("txt") {
649            txt_files.push(path);
650        }
651    }
652
653    if txt_files.is_empty() {
654        return Err(ParseBenchesError::NoTxtFiles(results_dir.to_path_buf()));
655    }
656
657    txt_files.sort();
658
659    let mut benchmarks = BTreeMap::new();
660    for file_path in &txt_files {
661        parse_bencher_file(file_path, &mut benchmarks)?;
662    }
663
664    if benchmarks.is_empty() {
665        return Err(ParseBenchesError::NoMetricsParsed(
666            results_dir.to_path_buf(),
667        ));
668    }
669
670    Ok(benchmarks)
671}
672
673/// Formats a [`SystemTime`] as an ISO8601 / RFC3339 UTC string (`YYYY-MM-DDTHH:MM:SSZ`).
674#[must_use]
675pub fn format_rfc3339_utc(time: SystemTime) -> String {
676    let offset_time = time::OffsetDateTime::from(time)
677        .replace_nanosecond(0)
678        .unwrap_or_else(|_| time::OffsetDateTime::from(time));
679    offset_time
680        .format(&Rfc3339)
681        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned())
682}
683
684/// Resolves commit SHA from an optional CLI argument, falling back to an optional environment SHA.
685#[must_use]
686pub fn resolve_commit_sha(commit_arg: Option<&str>, env_sha: Option<&str>) -> String {
687    let raw = commit_arg
688        .and_then(|s| {
689            let t = s.trim();
690            (!t.is_empty()).then_some(t)
691        })
692        .or_else(|| {
693            let s = env_sha?;
694            let t = s.trim();
695            (!t.is_empty()).then_some(t)
696        });
697
698    let Some(trimmed) = raw else {
699        return "unknown".to_owned();
700    };
701
702    trimmed.to_owned()
703}
704
705/// Generates a [`BenchmarkSummary`] from the provided command-line arguments.
706///
707/// # Errors
708///
709/// Returns [`ParseBenchesError`] if directory scanning or metric parsing fails.
710pub fn generate_summary(args: &Args) -> Result<BenchmarkSummary, ParseBenchesError> {
711    let benchmarks = parse_benchmark_dir(&args.results_dir)?;
712    let env_sha = env::var("GITHUB_SHA").ok();
713    let commit = resolve_commit_sha(args.commit.as_deref(), env_sha.as_deref());
714    let timestamp = format_rfc3339_utc(SystemTime::now());
715    let executables = match (&args.executables, &args.cargo_log) {
716        (Some(manifest), Some(cargo_log)) => {
717            let manifest: ExecutableManifest = serde_json::from_reader(File::open(manifest)?)?;
718            let source_dir = args.source_dir.clone().unwrap_or_default();
719            verify_pass(
720                &manifest,
721                &fs::read_to_string(cargo_log)?,
722                &source_dir,
723                &commit,
724            )?
725        }
726        _ => Vec::new(),
727    };
728
729    Ok(BenchmarkSummary {
730        suite: args.suite.clone(),
731        commit,
732        timestamp,
733        benchmarks,
734        executables,
735    })
736}
737
738/// Executes benchmark parsing and writes the resulting JSON summary to disk.
739///
740/// # Errors
741///
742/// Returns [`ParseBenchesError`] if parsing, directory creation, or file write fails.
743pub fn run_from_args(args: &Args) -> Result<BenchmarkSummary, ParseBenchesError> {
744    let summary = generate_summary(args)?;
745
746    let out_path = args
747        .output
748        .clone()
749        .unwrap_or_else(|| args.results_dir.join("broker-benchmarks.json"));
750
751    if let Some(parent) = out_path.parent() {
752        fs::create_dir_all(parent)?;
753    }
754
755    let json_bytes = serde_json::to_vec_pretty(&summary)?;
756    let mut file = File::create(&out_path)?;
757    file.write_all(&json_bytes)?;
758    file.write_all(b"\n")?;
759
760    Ok(summary)
761}
762
763fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), BudgetError> {
764    if let Some(parent) = path.parent() {
765        fs::create_dir_all(parent)?;
766    }
767    let mut bytes = serde_json::to_vec_pretty(value)?;
768    bytes.push(b'\n');
769    fs::write(path, bytes)?;
770    Ok(())
771}
772
773/// Run one subcommand and say what it found.
774///
775/// The returned message is printed on success. `Ok(false)` means the command
776/// ran and the comparison was rejected; the verdict and Markdown are written
777/// either way, so a rejection is published with its evidence.
778///
779/// # Errors
780///
781/// Returns [`ParseBenchesError`] when an input cannot be read, parsed or
782/// judged.
783pub fn run_command(command: &Command) -> Result<(bool, String), ParseBenchesError> {
784    match command {
785        Command::Executables {
786            cargo_messages,
787            commit,
788            output,
789        } => {
790            let manifest =
791                manifest_from_cargo_messages(&fs::read_to_string(cargo_messages)?, commit)?;
792            write_json(output, &manifest)?;
793            Ok((
794                true,
795                format!(
796                    "{} bench executables of {commit} recorded in {}",
797                    manifest.executables.len(),
798                    output.display()
799                ),
800            ))
801        }
802        Command::CheckContract { contract } => {
803            let bytes = fs::read(contract)?;
804            let parsed = Contract::from_json(&bytes)?;
805            Ok((
806                true,
807                format!(
808                    "{}: {} cells, {} rounds, sha256 {}",
809                    parsed.name,
810                    parsed.cells.len(),
811                    parsed.rounds,
812                    contract_digest(&bytes)
813                ),
814            ))
815        }
816        Command::Assemble {
817            artifact_dir,
818            output,
819        } => {
820            let record = assemble(artifact_dir)?;
821            write_json(output, &record)?;
822            Ok((
823                true,
824                format!(
825                    "{} trials written to {}",
826                    record.trials.len(),
827                    output.display()
828                ),
829            ))
830        }
831        Command::Evaluate {
832            record,
833            contract,
834            output,
835            markdown,
836        } => {
837            let contract_bytes = fs::read(contract)?;
838            let parsed = Contract::from_json(&contract_bytes)?;
839            let record = PerformanceRecord::from_json(&fs::read(record)?)?;
840            let verdict = evaluate(&parsed, &contract_bytes, &record)?;
841            write_json(output, &verdict)?;
842            if let Some(markdown) = markdown {
843                if let Some(parent) = markdown.parent() {
844                    fs::create_dir_all(parent)?;
845                }
846                fs::write(markdown, render_markdown(&parsed, &record, &verdict))?;
847            }
848            let summary = if verdict.passed {
849                format!("performance budget passed: {}", output.display())
850            } else {
851                format!(
852                    "performance budget rejected ({} reasons): {}",
853                    verdict.rejections.len(),
854                    output.display()
855                )
856            };
857            Ok((verdict.passed, summary))
858        }
859    }
860}