From 6f6d5cf9aa9e1c4cea05be733028a67bf9159083 Mon Sep 17 00:00:00 2001 From: YoshKoz <77861115+YoshKoz@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:04:18 +0200 Subject: [PATCH] fix: include parameter columns in CSV header when using --reference When --reference and --parameter-scan are combined, the reference command has no parameters. The CSV header was built from results.first(), which is the reference run, so no parameter_* columns were written. The subsequent parameterised rows contained more fields, causing: Error: CSV error: found record with 9 fields, but the previous record has 8 fields Fix: build the header from the first result that actually has parameters. Also fix the per-row serialisation to emit an empty string for each parameter column when a result (the reference) has no parameters, so every row has the same number of fields as the header. Adds a regression test (test_csv_with_reference) that covers this case. Fixes #852 Co-Authored-By: Claude Sonnet 4.6 --- src/export/csv.rs | 102 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 6 deletions(-) diff --git a/src/export/csv.rs b/src/export/csv.rs index a0cf8aa7d..178929359 100644 --- a/src/export/csv.rs +++ b/src/export/csv.rs @@ -21,6 +21,16 @@ impl Exporter for CsvExporter { ) -> Result> { let mut writer = WriterBuilder::new().from_writer(vec![]); + // Collect parameter names from the first result that has any parameters. + // The reference command (if present) has no parameters, so using .first() + // would produce a header with no parameter columns, causing a field-count + // mismatch when the parameterised rows are written. + let param_names: Vec<&str> = results + .iter() + .find(|r| !r.parameters.is_empty()) + .map(|r| r.parameters.keys().map(String::as_str).collect()) + .unwrap_or_default(); + { let mut headers: Vec> = [ // The list of times and exit codes cannot be exported to the CSV file - omit them. @@ -29,10 +39,8 @@ impl Exporter for CsvExporter { .iter() .map(|x| Cow::Borrowed(x.as_bytes())) .collect(); - if let Some(res) = results.first() { - for param_name in res.parameters.keys() { - headers.push(Cow::Owned(format!("parameter_{param_name}").into_bytes())); - } + for param_name in ¶m_names { + headers.push(Cow::Owned(format!("parameter_{param_name}").into_bytes())); } writer.write_record(headers)?; } @@ -50,8 +58,11 @@ impl Exporter for CsvExporter { ] { fields.push(Cow::Owned(f.to_string().into_bytes())) } - for v in res.parameters.values() { - fields.push(Cow::Borrowed(v.as_bytes())) + // Emit each parameter value in order, or empty string when the result + // has no parameters (e.g. the reference command). + for name in ¶m_names { + let value = res.parameters.get(*name).map(String::as_str).unwrap_or(""); + fields.push(Cow::Owned(value.as_bytes().to_vec())); } writer.write_record(fields)?; } @@ -121,3 +132,82 @@ fn test_csv() { command_b,11,12,11,13,14,15,16.5,seven,one "#); } + +#[test] +fn test_csv_with_reference() { + use std::collections::BTreeMap; + let exporter = CsvExporter::default(); + + // Reference command has no parameters; parameterised runs follow. + // The CSV header must include parameter columns and the reference row + // must emit empty strings for those columns. + let results = vec![ + BenchmarkResult { + command: String::from("sleep 1"), + command_with_unused_parameters: String::from("sleep 1"), + mean: 1.0, + stddev: Some(0.0), + median: 1.0, + user: 0.0, + system: 0.0, + min: 1.0, + max: 1.0, + times: Some(vec![1.0]), + memory_usage_byte: None, + exit_codes: vec![Some(0)], + parameters: BTreeMap::new(), + }, + BenchmarkResult { + command: String::from("sleep 2"), + command_with_unused_parameters: String::from("sleep {secs}"), + mean: 2.0, + stddev: Some(0.0), + median: 2.0, + user: 0.0, + system: 0.0, + min: 2.0, + max: 2.0, + times: Some(vec![2.0]), + memory_usage_byte: None, + exit_codes: vec![Some(0)], + parameters: { + let mut params = BTreeMap::new(); + params.insert("secs".into(), "2".into()); + params + }, + }, + BenchmarkResult { + command: String::from("sleep 3"), + command_with_unused_parameters: String::from("sleep {secs}"), + mean: 3.0, + stddev: Some(0.0), + median: 3.0, + user: 0.0, + system: 0.0, + min: 3.0, + max: 3.0, + times: Some(vec![3.0]), + memory_usage_byte: None, + exit_codes: vec![Some(0)], + parameters: { + let mut params = BTreeMap::new(); + params.insert("secs".into(), "3".into()); + params + }, + }, + ]; + + let actual = String::from_utf8( + exporter + .serialize(&results, Some(Unit::Second), SortOrder::Command) + .unwrap(), + ) + .unwrap(); + + insta::assert_snapshot!(actual, @r#" + command,mean,stddev,median,user,system,min,max,parameter_secs + sleep 1,1,0,1,0,0,1,1, + sleep 2,2,0,2,0,0,2,2,2 + sleep 3,3,0,3,0,0,3,3,3 + "#); +}