From 830f631f69ea707b71c795fc4c2be302f50330d9 Mon Sep 17 00:00:00 2001 From: Guillaume Lagrange Date: Thu, 19 Mar 2026 16:59:01 +0100 Subject: [PATCH 1/3] feat: hide the cursor while running --- src/main.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 518b8d3e..35cef6f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,26 @@ use codspeed_runner::{clean_logger, cli}; -use console::style; +use console::{Term, style}; use log::log_enabled; +struct HiddenCursor(Term); + +impl HiddenCursor { + fn new() -> Self { + let term = Term::stderr(); + let _ = term.hide_cursor(); + Self(term) + } +} + +impl Drop for HiddenCursor { + fn drop(&mut self) { + let _ = self.0.show_cursor(); + } +} + #[tokio::main(flavor = "current_thread")] async fn main() { + let _cursor = HiddenCursor::new(); let res = cli::run().await; if let Err(err) = res { // Show the primary error From 74850370c5449fcd5974419bf82004417b4d003f Mon Sep 17 00:00:00 2001 From: Guillaume Lagrange Date: Fri, 20 Mar 2026 15:30:31 +0100 Subject: [PATCH 2/3] feat: add --base argument to compare local run to another run Add --base cli argument and simplify polling code 1. If a base run id is provided, we now use the `compareRunsPaginated` resolver and display results from there 2. If a base run id is not provided or if the base run id is not correct (missing, executor mismatch...), we display the single mode results This overall simplifies the polling code and gets rid of the artificial "for_run" and "for_exec" distinctions. --- src/api_client.rs | 200 ++++++++----- src/cli/exec/mod.rs | 10 +- src/cli/run/helpers/benchmark_display.rs | 100 ++++++- src/cli/run/mod.rs | 13 +- src/cli/shared.rs | 4 + src/executor/config.rs | 2 +- src/queries/CompareRuns.gql | 23 ++ ...chLocalRunReport.gql => FetchLocalRun.gql} | 10 +- src/upload/mod.rs | 2 - src/upload/poll_results.rs | 267 ++++++++++++++---- src/upload/polling.rs | 58 ---- 11 files changed, 491 insertions(+), 198 deletions(-) create mode 100644 src/queries/CompareRuns.gql rename src/queries/{FetchLocalRunReport.gql => FetchLocalRun.gql} (72%) delete mode 100644 src/upload/polling.rs diff --git a/src/api_client.rs b/src/api_client.rs index 69b1d892..80ce8f8c 100644 --- a/src/api_client.rs +++ b/src/api_client.rs @@ -81,39 +81,12 @@ nest! { #[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] -pub struct FetchLocalRunReportVars { +pub struct FetchLocalRunVars { pub owner: String, pub name: String, pub run_id: String, } -#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] -pub enum ReportConclusion { - AcknowledgedFailure, - Failure, - MissingBaseRun, - NoBenchmarks, - Success, -} - -impl Display for ReportConclusion { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ReportConclusion::AcknowledgedFailure => { - write!(f, "{}", style("Acknowledged Failure").yellow().bold()) - } - ReportConclusion::Failure => write!(f, "{}", style("Failure").red().bold()), - ReportConclusion::MissingBaseRun => { - write!(f, "{}", style("Missing Base Run").yellow().bold()) - } - ReportConclusion::NoBenchmarks => { - write!(f, "{}", style("No Benchmarks").yellow().bold()) - } - ReportConclusion::Success => write!(f, "{}", style("Success").green().bold()), - } - } -} - #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum RunStatus { @@ -123,18 +96,23 @@ pub enum RunStatus { Processing, } +// Custom deserializer to convert string values to i64 +fn deserialize_i64_from_string<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de; + let s = String::deserialize(deserializer)?; + s.parse().map_err(de::Error::custom) +} + nest! { #[derive(Debug, Deserialize, Serialize)]* #[serde(rename_all = "camelCase")]* - pub struct FetchLocalRunReportRun { + pub struct FetchLocalRunRun { pub id: String, pub status: RunStatus, pub url: String, - pub head_reports: Vec, - pub conclusion: ReportConclusion, - }>, pub results: Vec(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - use serde::de; - let s = String::deserialize(deserializer)?; - s.parse().map_err(de::Error::custom) +nest! { + #[derive(Debug, Deserialize, Serialize)]* + #[serde(rename_all = "camelCase")]* + struct FetchLocalRunData { + repository: struct FetchLocalRunRepository { + run: FetchLocalRunRun, + } + } +} + +pub struct FetchLocalRunResponse { + pub run: FetchLocalRunRun, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CompareRunsVars { + pub owner: String, + pub name: String, + pub base_run_id: String, + pub head_run_id: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "PascalCase")] +pub enum ResultComparisonCategory { + Acknowledged, + Archived, + Ignored, + Improvement, + New, + Regression, + Skipped, + Untouched, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub enum BenchmarkReportStatus { + Improvement, + Missing, + New, + NoChange, + Regression, +} + +impl Display for BenchmarkReportStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BenchmarkReportStatus::Improvement => { + write!(f, "{}", style("Improvement").green().bold()) + } + BenchmarkReportStatus::Missing => write!(f, "{}", style("Missing").yellow().bold()), + BenchmarkReportStatus::New => write!(f, "{}", style("New").cyan().bold()), + BenchmarkReportStatus::NoChange => write!(f, "{}", style("No Change").dim()), + BenchmarkReportStatus::Regression => write!(f, "{}", style("Regression").red().bold()), + } + } } nest! { #[derive(Debug, Deserialize, Serialize)]* #[serde(rename_all = "camelCase")]* - struct FetchLocalRunReportData { - repository: pub struct FetchLocalRunReportRepository { - settings: struct FetchLocalRunReportSettings { - allowed_regression: f64, - }, - run: FetchLocalRunReportRun, - } + pub struct CompareRunsBenchmarkResult { + pub value: Option, + pub base_value: Option, + pub change: Option, + pub category: ResultComparisonCategory, + pub status: BenchmarkReportStatus, + pub benchmark: pub struct CompareRunsBenchmark { + pub name: String, + pub executor: ExecutorName, + }, + } +} + +nest! { + #[derive(Debug, Deserialize, Serialize)]* + #[serde(rename_all = "camelCase")]* + pub struct CompareRunsHeadRun { + pub id: String, + pub status: RunStatus, } } nest! { #[derive(Debug, Deserialize, Serialize)]* #[serde(rename_all = "camelCase")]* - struct FetchLocalExecReportData { - project: pub struct FetchLocalExecReportProject { - run: FetchLocalRunReportRun, + struct CompareRunsData { + repository: struct CompareRunsRepository { + paginated_compare_runs: pub struct CompareRunsComparison { + pub impact: Option, + pub url: String, + pub head_run: CompareRunsHeadRun, + pub result_comparisons: Vec, + }, } } } -pub struct FetchLocalRunReportResponse { - pub allowed_regression: f64, - pub run: FetchLocalRunReportRun, +pub struct CompareRunsResponse { + pub comparison: CompareRunsComparison, +} + +pub enum CompareRunsOutcome { + Success(CompareRunsResponse), + BaseRunNotFound, + ExecutorMismatch, } #[derive(Serialize, Clone)] @@ -274,26 +323,47 @@ impl CodSpeedAPIClient { } } - pub async fn fetch_local_run_report( - &self, - vars: FetchLocalRunReportVars, - ) -> Result { + pub async fn compare_runs(&self, vars: CompareRunsVars) -> Result { let response = self .gql_client - .query_with_vars_unwrap::( - include_str!("queries/FetchLocalRunReport.gql"), - vars.clone(), + .query_with_vars_unwrap::( + include_str!("queries/CompareRuns.gql"), + vars, + ) + .await; + match response { + Ok(response) => Ok(CompareRunsOutcome::Success(CompareRunsResponse { + comparison: response.repository.paginated_compare_runs, + })), + Err(err) if err.contains_error_code("UNAUTHENTICATED") => { + bail!("Your session has expired, please login again using `codspeed auth login`") + } + Err(err) if err.contains_error_code("RUN_NOT_FOUND") => { + Ok(CompareRunsOutcome::BaseRunNotFound) + } + Err(err) if err.contains_error_code("NOT_FOUND") => { + Ok(CompareRunsOutcome::ExecutorMismatch) + } + Err(err) => bail!("Failed to compare runs: {err:?}"), + } + } + + pub async fn fetch_local_run(&self, vars: FetchLocalRunVars) -> Result { + let response = self + .gql_client + .query_with_vars_unwrap::( + include_str!("queries/FetchLocalRun.gql"), + vars, ) .await; match response { - Ok(response) => Ok(FetchLocalRunReportResponse { - allowed_regression: response.repository.settings.allowed_regression, + Ok(response) => Ok(FetchLocalRunResponse { run: response.repository.run, }), Err(err) if err.contains_error_code("UNAUTHENTICATED") => { bail!("Your session has expired, please login again using `codspeed auth login`") } - Err(err) => bail!("Failed to fetch local run report: {err}"), + Err(err) => bail!("Failed to fetch local run: {err}"), } } diff --git a/src/cli/exec/mod.rs b/src/cli/exec/mod.rs index 3d6a77ed..765f78ec 100644 --- a/src/cli/exec/mod.rs +++ b/src/cli/exec/mod.rs @@ -55,6 +55,7 @@ impl ExecArgs { fn build_orchestrator_config( args: ExecArgs, target: executor::BenchmarkTarget, + poll_results_options: PollResultsOptions, ) -> Result { let modes = args.shared.resolve_modes()?; let raw_upload_url = args @@ -86,7 +87,7 @@ fn build_orchestrator_config( allow_empty: args.shared.allow_empty, go_runner_version: args.shared.go_runner_version, show_full_output: args.shared.show_full_output, - poll_results_options: PollResultsOptions::for_exec(), + poll_results_options, extra_env: HashMap::new(), }) } @@ -99,12 +100,17 @@ pub async fn run( setup_cache_dir: Option<&Path>, ) -> Result<()> { let merged_args = args.merge_with_project_config(project_config); + let base_run_id = merged_args.shared.base.clone(); let target = executor::BenchmarkTarget::Exec { command: merged_args.command.clone(), name: merged_args.name.clone(), walltime_args: merged_args.walltime_args.clone(), }; - let config = build_orchestrator_config(merged_args, target)?; + let config = build_orchestrator_config( + merged_args, + target, + PollResultsOptions::new(false, base_run_id), + )?; execute_config(config, api_client, codspeed_config, setup_cache_dir).await } diff --git a/src/cli/run/helpers/benchmark_display.rs b/src/cli/run/helpers/benchmark_display.rs index d9764db8..52d75404 100644 --- a/src/cli/run/helpers/benchmark_display.rs +++ b/src/cli/run/helpers/benchmark_display.rs @@ -1,4 +1,6 @@ -use crate::api_client::FetchLocalRunBenchmarkResult; +use crate::api_client::{ + CompareRunsBenchmarkResult, FetchLocalRunBenchmarkResult, ResultComparisonCategory, +}; use crate::cli::run::helpers; use crate::executor::ExecutorName; use console::style; @@ -317,6 +319,102 @@ pub fn build_detailed_summary(result: &FetchLocalRunBenchmarkResult) -> String { } } +#[derive(Tabled)] +struct ComparisonRow { + #[tabled(rename = "Benchmark")] + name: String, + #[tabled(rename = "Base")] + base_value: String, + #[tabled(rename = "Head")] + head_value: String, + #[tabled(rename = "Change")] + change: String, + #[tabled(rename = "Status")] + status: String, +} + +pub fn build_comparison_table(results: &[CompareRunsBenchmarkResult]) -> String { + let mut grouped: HashMap<&ExecutorName, Vec<&CompareRunsBenchmarkResult>> = HashMap::new(); + for result in results { + grouped + .entry(&result.benchmark.executor) + .or_default() + .push(result); + } + + let executor_order = [ + ExecutorName::Valgrind, + ExecutorName::WallTime, + ExecutorName::Memory, + ]; + + let mut output = String::new(); + for executor in &executor_order { + if let Some(executor_results) = grouped.get(executor) { + if !output.is_empty() { + output.push('\n'); + } + let rows: Vec = executor_results + .iter() + .map(|result| { + let format_value = |v: Option| match v { + Some(v) => match executor { + ExecutorName::Memory => helpers::format_memory(v, Some(1)), + _ => helpers::format_duration(v, Some(2)), + }, + None => "-".to_string(), + }; + + let change_str = match result.change { + Some(c) if c > 0.0 => { + let pct = (c * 100.0).round(); + format!("{}", style(format!("+{pct}%")).red().bold()) + } + Some(c) if c < 0.0 => { + let pct = (c * 100.0).round(); + format!("{}", style(format!("{pct}%")).green().bold()) + } + Some(_) => format!("{}", style("0%").dim()), + None => "-".to_string(), + }; + + let status_str = match &result.category { + ResultComparisonCategory::New => { + format!("{}", style("New").cyan().bold()) + } + ResultComparisonCategory::Improvement => { + format!("{}", style("Improvement").green().bold()) + } + ResultComparisonCategory::Regression => { + format!("{}", style("Regression").red().bold()) + } + ResultComparisonCategory::Untouched => { + format!("{}", style("No Change").dim()) + } + _ => format!("{}", &result.status), + }; + + ComparisonRow { + name: result.benchmark.name.clone(), + base_value: format_value(result.base_value), + head_value: format!("{}", style(format_value(result.value)).cyan()), + change: change_str, + status: status_str, + } + }) + .collect(); + + output.push_str(&build_table_with_style( + &rows, + executor.label(), + executor.icon(), + )); + } + } + + output +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/cli/run/mod.rs b/src/cli/run/mod.rs index 045c53f0..2036b34e 100644 --- a/src/cli/run/mod.rs +++ b/src/cli/run/mod.rs @@ -67,6 +67,7 @@ impl RunArgs { allow_empty: false, go_runner_version: None, show_full_output: false, + base: None, perf_run_args: PerfRunArgs { enable_perf: false, perf_unwinding_mode: None, @@ -143,6 +144,7 @@ pub async fn run( ) -> Result<()> { let output_json = args.message_format == Some(MessageFormat::Json); let project_config = discovered_config.map(|d| &d.config); + let base_run_id = args.shared.base.clone(); let run_target = if args.command.is_empty() { // No command provided - check for targets in project config @@ -171,13 +173,14 @@ pub async fn run( // SingleCommand: working_directory comes from --working-directory CLI flag only. // Config file's working-directory is NOT used. let command = args.command.join(" "); + let poll_opts = PollResultsOptions::new(output_json, base_run_id); let config = build_orchestrator_config( args, vec![executor::BenchmarkTarget::Entrypoint { command, name: None, }], - PollResultsOptions::for_run(output_json), + poll_opts, )?; let orchestrator = @@ -230,10 +233,12 @@ pub async fn run( let benchmark_targets = super::exec::multi_targets::build_benchmark_targets(targets, default_walltime)?; - let mut config = - build_orchestrator_config(args, benchmark_targets, PollResultsOptions::for_exec())?; + let mut config = build_orchestrator_config( + args, + benchmark_targets, + PollResultsOptions::new(false, base_run_id), + )?; config.working_directory = resolved_working_directory; - super::exec::execute_config(config, api_client, codspeed_config, setup_cache_dir) .await?; } diff --git a/src/cli/shared.rs b/src/cli/shared.rs index 15b95423..01793cd9 100644 --- a/src/cli/shared.rs +++ b/src/cli/shared.rs @@ -109,6 +109,10 @@ pub struct ExecAndRunSharedArgs { #[arg(long, default_value = "false")] pub show_full_output: bool, + /// Compare the results against this base run ID + #[arg(long)] + pub base: Option, + #[command(flatten)] pub perf_run_args: PerfRunArgs, } diff --git a/src/executor/config.rs b/src/executor/config.rs index e13c3aa9..390673a9 100644 --- a/src/executor/config.rs +++ b/src/executor/config.rs @@ -221,7 +221,7 @@ impl OrchestratorConfig { allow_empty: false, go_runner_version: None, show_full_output: false, - poll_results_options: PollResultsOptions::for_exec(), + poll_results_options: PollResultsOptions::new(false, None), extra_env: HashMap::new(), } } diff --git a/src/queries/CompareRuns.gql b/src/queries/CompareRuns.gql new file mode 100644 index 00000000..9c9b0743 --- /dev/null +++ b/src/queries/CompareRuns.gql @@ -0,0 +1,23 @@ +query CompareRuns($owner: String!, $name: String!, $baseRunId: String!, $headRunId: String!) { + repository(owner: $owner, name: $name) { + paginatedCompareRuns(baseRunId: $baseRunId, headRunId: $headRunId) { + impact + url + headRun { + id + status + } + resultComparisons { + benchmark { + name + executor + } + value + baseValue + change + category + status + } + } + } +} diff --git a/src/queries/FetchLocalRunReport.gql b/src/queries/FetchLocalRun.gql similarity index 72% rename from src/queries/FetchLocalRunReport.gql rename to src/queries/FetchLocalRun.gql index 17f56a5b..4c211062 100644 --- a/src/queries/FetchLocalRunReport.gql +++ b/src/queries/FetchLocalRun.gql @@ -1,17 +1,9 @@ -query FetchLocalRunReport($owner: String!, $name: String!, $runId: String!) { +query FetchLocalRun($owner: String!, $name: String!, $runId: String!) { repository(owner: $owner, name: $name) { - settings { - allowedRegression - } run(id: $runId) { id status url - headReports { - id - impact - conclusion - } results { benchmark { name diff --git a/src/upload/mod.rs b/src/upload/mod.rs index 9ba5d14d..b4be2739 100644 --- a/src/upload/mod.rs +++ b/src/upload/mod.rs @@ -1,13 +1,11 @@ mod interfaces; pub mod poll_results; -mod polling; mod profile_archive; mod run_index_state; mod upload_metadata; mod uploader; pub use interfaces::*; -pub use polling::poll_run_report; pub use profile_archive::ProfileArchive; pub use run_index_state::RunIndexState; pub use uploader::{UploadResult, upload}; diff --git a/src/upload/poll_results.rs b/src/upload/poll_results.rs index 2683e637..4ad51bba 100644 --- a/src/upload/poll_results.rs +++ b/src/upload/poll_results.rs @@ -1,39 +1,38 @@ +use std::future::Future; +use std::time::Duration; + use console::style; +use tokio::time::{Instant, sleep}; -use crate::api_client::CodSpeedAPIClient; -use crate::cli::run::helpers::benchmark_display::{build_benchmark_table, build_detailed_summary}; +use crate::api_client::{ + CodSpeedAPIClient, CompareRunsResponse, CompareRunsVars, FetchLocalRunResponse, + FetchLocalRunVars, RunStatus, +}; +use crate::cli::run::helpers::benchmark_display::{ + build_benchmark_table, build_comparison_table, build_detailed_summary, +}; use crate::local_logger::{start_spinner, stop_spinner}; use crate::prelude::*; -use super::{UploadResult, poll_run_report}; +use super::UploadResult; + +const RUN_PROCESSING_MAX_DURATION: Duration = Duration::from_secs(60 * 5); // 5 minutes +const POLLING_INTERVAL: Duration = Duration::from_secs(1); /// Options controlling poll_results display behavior. #[derive(Debug, Clone)] pub struct PollResultsOptions { - /// If true, show impact percentage (used by `codspeed run`) - pub show_impact: bool, /// If true, output JSON events (used by `codspeed run --message-format json`) pub output_json: bool, - /// If true, show detailed summary for single benchmark result (used by `codspeed exec`) - pub detailed_single: bool, + /// If set, compare the uploaded run against this base run ID + pub base_run_id: Option, } impl PollResultsOptions { - /// Options for `codspeed run` - pub fn for_run(output_json: bool) -> Self { + pub fn new(output_json: bool, base_run_id: Option) -> Self { Self { - show_impact: true, output_json, - detailed_single: false, - } - } - - /// Options for `codspeed exec` - pub fn for_exec() -> Self { - Self { - show_impact: false, - output_json: false, - detailed_single: true, + base_run_id, } } } @@ -43,45 +42,123 @@ pub async fn poll_results( upload_result: &UploadResult, options: &PollResultsOptions, ) -> Result<()> { - start_spinner("Waiting for results"); - let response = poll_run_report(api_client, upload_result).await; - stop_spinner(); - let response = response?; - - if options.show_impact { - let report = response.run.head_reports.into_iter().next(); - if let Some(report) = report { - if let Some(impact) = report.impact { - let rounded_impact = (impact * 100.0).round(); - let (arrow, impact_text) = if impact > 0.0 { - ( - style("\u{f062}").green(), - style(format!("+{rounded_impact}%")).green().bold(), - ) - } else if impact < 0.0 { - ( - style("\u{f063}").red(), - style(format!("{rounded_impact}%")).red().bold(), - ) - } else { - ( - style("\u{25CF}").dim(), - style(format!("{rounded_impact}%")).dim().bold(), - ) - }; - - let allowed = (response.allowed_regression * 100.0).round(); - info!("{arrow} Impact: {impact_text} (allowed regression: -{allowed}%)"); - } else { - info!( - "{} No impact detected, reason: {}", - style("\u{25CB}").dim(), - report.conclusion + if let Some(base_run_id) = &options.base_run_id { + start_spinner("Waiting for results"); + let compare_result = poll_compare_runs(api_client, upload_result, base_run_id).await; + stop_spinner(); + + match compare_result? { + CompareRunsOutcome::Success(response) => { + return display_comparison_results(upload_result, options, response).await; + } + // Fall back to single run display when comparison is not possible + CompareRunsOutcome::BaseRunNotFound => { + warn!( + "Base run ID \"{base_run_id}\" was not found, we cannot compare results against it." + ); + } + CompareRunsOutcome::ExecutorMismatch => { + warn!( + "Base run ID \"{base_run_id}\" uses a different executor, we cannot compare results against it." ); } } } + start_spinner("Waiting for results"); + let response = poll_local_run(api_client, upload_result).await; + stop_spinner(); + + display_single_run_results(upload_result, options, response?).await +} + +/// Poll using `fetch` until `get_status` returns neither Pending nor Processing, then return +/// the response or an error if the status is Failure or polling times out. +/// +/// If `fetch` returns `Ok(None)`, polling stops immediately and `Ok(None)` is returned. +async fn poll_until_processed( + fetch: impl Fn() -> Fut, + get_status: impl Fn(&T) -> &RunStatus, +) -> Result> +where + Fut: Future>>, +{ + let start = Instant::now(); + debug!("Waiting for results to be processed..."); + + loop { + if start.elapsed() > RUN_PROCESSING_MAX_DURATION { + bail!("Polling results timed out after 5 minutes. Please try again later."); + } + + let Some(response) = fetch().await? else { + return Ok(None); + }; + match get_status(&response) { + RunStatus::Pending | RunStatus::Processing => sleep(POLLING_INTERVAL).await, + RunStatus::Failure => bail!("Run failed to be processed, try again in a few minutes"), + _ => return Ok(Some(response)), + } + } +} + +async fn poll_local_run( + api_client: &CodSpeedAPIClient, + upload_result: &UploadResult, +) -> Result { + let vars = FetchLocalRunVars { + owner: upload_result.owner.clone(), + name: upload_result.repository.clone(), + run_id: upload_result.run_id.clone(), + }; + // fetch_local_run always returns Some — wrap to satisfy the shared signature + poll_until_processed( + || async { api_client.fetch_local_run(vars.clone()).await.map(Some) }, + |r: &FetchLocalRunResponse| &r.run.status, + ) + .await? + .ok_or_else(|| anyhow::anyhow!("unexpected None response from fetch_local_run")) +} + +async fn poll_compare_runs( + api_client: &CodSpeedAPIClient, + upload_result: &UploadResult, + base_run_id: &str, +) -> Result { + let vars = CompareRunsVars { + owner: upload_result.owner.clone(), + name: upload_result.repository.clone(), + base_run_id: base_run_id.to_string(), + head_run_id: upload_result.run_id.clone(), + }; + + let start = Instant::now(); + debug!("Waiting for results to be processed..."); + + loop { + if start.elapsed() > RUN_PROCESSING_MAX_DURATION { + bail!("Polling results timed out after 5 minutes. Please try again later."); + } + + match api_client.compare_runs(vars.clone()).await? { + outcome @ (CompareRunsOutcome::BaseRunNotFound + | CompareRunsOutcome::ExecutorMismatch) => return Ok(outcome), + CompareRunsOutcome::Success(response) => match &response.comparison.head_run.status { + RunStatus::Pending | RunStatus::Processing => sleep(POLLING_INTERVAL).await, + RunStatus::Failure => { + bail!("Run failed to be processed, try again in a few minutes") + } + _ => return Ok(CompareRunsOutcome::Success(response)), + }, + } + } +} + +async fn display_single_run_results( + upload_result: &UploadResult, + options: &PollResultsOptions, + response: FetchLocalRunResponse, +) -> Result<()> { if options.output_json { log_json!(format!( "{{\"event\": \"run_finished\", \"run_id\": \"{}\"}}", @@ -97,7 +174,7 @@ pub async fn poll_results( end_group!(); start_opened_group!("Benchmark results"); - if options.detailed_single && response.run.results.len() == 1 { + if response.run.results.len() == 1 { let summary = build_detailed_summary(&response.run.results[0]); info!("{summary}\n"); } else { @@ -114,11 +191,89 @@ pub async fn poll_results( } } + let run_id = &upload_result.run_id; info!( "\n{} {}", style("View full report:").dim(), - style(response.run.url).blue().bold().underlined() + style(&response.run.url).blue().bold().underlined(), + ); + show_comparison_suggestion(run_id); + } + + Ok(()) +} + +fn show_comparison_suggestion(run_id: &str) { + info!( + "\n{} {}", + style("To compare future runs against this one, use:").dim(), + style(format!("--base {run_id}")).cyan(), + ); +} + +async fn display_comparison_results( + upload_result: &UploadResult, + options: &PollResultsOptions, + response: CompareRunsResponse, +) -> Result<()> { + let comparison = &response.comparison; + + if options.output_json { + log_json!(format!( + "{{\"event\": \"run_finished\", \"run_id\": \"{}\"}}", + upload_result.run_id + )); + } + + if comparison.result_comparisons.is_empty() { + warn!( + "No benchmarks were found in the run. Make sure your command runs benchmarks that are instrumented with a CodSpeed integration." + ); + } else { + end_group!(); + start_opened_group!("Benchmark results"); + + if let Some(impact) = comparison.impact { + let pct = impact * 100.0; + let (arrow, impact_text) = if impact.abs() < benchmark_display::CHANGE_DISPLAY_EPSILON { + ( + style("\u{25CF}").dim(), + style(format!("{pct:.1}%")).dim().bold(), + ) + } else if impact > 0.0 { + ( + style("\u{f062}").green(), + style(format!("+{pct:.1}%")).green().bold(), + ) + } else { + ( + style("\u{f063}").red(), + style(format!("{pct:.1}%")).red().bold(), + ) + }; + info!("{arrow} Impact: {impact_text}"); + } + + let table = build_comparison_table(&comparison.result_comparisons); + info!("{table}\n"); + + if options.output_json { + for result in &comparison.result_comparisons { + if let Some(value) = result.value { + log_json!(format!( + "{{\"event\": \"benchmark_ran\", \"name\": \"{}\", \"time\": \"{value}\"}}", + result.benchmark.name + )); + } + } + } + + info!( + "\n{} {}", + style("View comparison report:").dim(), + style(&comparison.url).blue().bold().underlined() ); + show_comparison_suggestion(&upload_result.run_id); } Ok(()) diff --git a/src/upload/polling.rs b/src/upload/polling.rs deleted file mode 100644 index 29be3fbc..00000000 --- a/src/upload/polling.rs +++ /dev/null @@ -1,58 +0,0 @@ -use std::time::Duration; -use tokio::time::{Instant, sleep}; - -use crate::api_client::{ - CodSpeedAPIClient, FetchLocalRunReportResponse, FetchLocalRunReportVars, RunStatus, -}; -use crate::prelude::*; - -use super::UploadResult; - -pub const RUN_PROCESSING_MAX_DURATION: Duration = Duration::from_secs(60 * 5); // 5 minutes -pub const POLLING_INTERVAL: Duration = Duration::from_secs(1); - -/// Poll the API until the run is processed and return the response. -/// -/// Returns an error if polling times out or the run fails processing. -pub async fn poll_run_report( - api_client: &CodSpeedAPIClient, - upload_result: &UploadResult, -) -> Result { - let start = Instant::now(); - let fetch_local_run_report_vars = FetchLocalRunReportVars { - owner: upload_result.owner.clone(), - name: upload_result.repository.clone(), - run_id: upload_result.run_id.clone(), - }; - - debug!("Waiting for results to be processed..."); - - let response; - loop { - if start.elapsed() > RUN_PROCESSING_MAX_DURATION { - bail!("Polling results timed out after 5 minutes. Please try again later."); - } - - let fetch_result = api_client - .fetch_local_run_report(fetch_local_run_report_vars.clone()) - .await?; - - match fetch_result { - FetchLocalRunReportResponse { run, .. } - if run.status == RunStatus::Pending || run.status == RunStatus::Processing => - { - sleep(POLLING_INTERVAL).await; - } - response_from_api => { - response = response_from_api; - break; - } - } - } - - if response.run.status == RunStatus::Failure { - bail!("Run failed to be processed, try again in a few minutes"); - } - - Ok(response) -} From aaad11bba083e3e77f2eacff4240f7520d8a8757 Mon Sep 17 00:00:00 2001 From: Guillaume Lagrange Date: Fri, 20 Mar 2026 15:51:02 +0100 Subject: [PATCH 3/3] refactor: move the benchmark display helpers to the upload module --- src/cli/run/helpers/mod.rs | 1 - .../run/helpers => upload}/benchmark_display.rs | 15 +++++++++------ src/upload/mod.rs | 1 + src/upload/poll_results.rs | 10 +++++----- ...splay__tests__benchmark_table_formatting.snap} | 0 5 files changed, 15 insertions(+), 12 deletions(-) rename src/{cli/run/helpers => upload}/benchmark_display.rs (97%) rename src/{cli/run/helpers/snapshots/codspeed_runner__cli__run__helpers__benchmark_display__tests__benchmark_table_formatting.snap => upload/snapshots/codspeed_runner__upload__benchmark_display__tests__benchmark_table_formatting.snap} (100%) diff --git a/src/cli/run/helpers/mod.rs b/src/cli/run/helpers/mod.rs index f4c4097e..98af84a2 100644 --- a/src/cli/run/helpers/mod.rs +++ b/src/cli/run/helpers/mod.rs @@ -1,4 +1,3 @@ -pub(crate) mod benchmark_display; mod download_file; mod find_repository_root; mod format_duration; diff --git a/src/cli/run/helpers/benchmark_display.rs b/src/upload/benchmark_display.rs similarity index 97% rename from src/cli/run/helpers/benchmark_display.rs rename to src/upload/benchmark_display.rs index 52d75404..36533ebe 100644 --- a/src/cli/run/helpers/benchmark_display.rs +++ b/src/upload/benchmark_display.rs @@ -11,6 +11,9 @@ use tabled::settings::style::HorizontalLine; use tabled::settings::{Alignment, Color, Modify, Padding, Style}; use tabled::{Table, Tabled}; +/// Changes below this threshold are displayed as "~0%" to avoid noise. +pub(super) const CHANGE_DISPLAY_EPSILON: f64 = 0.005; + fn format_with_thousands_sep(n: u64) -> String { let s = n.to_string(); let mut result = String::new(); @@ -366,15 +369,15 @@ pub fn build_comparison_table(results: &[CompareRunsBenchmarkResult]) -> String }; let change_str = match result.change { + Some(c) if c.abs() < CHANGE_DISPLAY_EPSILON => { + format!("{}", style(format!("{:.1}%", c * 100.0)).dim()) + } Some(c) if c > 0.0 => { - let pct = (c * 100.0).round(); - format!("{}", style(format!("+{pct}%")).red().bold()) + format!("{}", style(format!("+{:.1}%", c * 100.0)).green().bold()) } - Some(c) if c < 0.0 => { - let pct = (c * 100.0).round(); - format!("{}", style(format!("{pct}%")).green().bold()) + Some(c) => { + format!("{}", style(format!("{:.1}%", c * 100.0)).red().bold()) } - Some(_) => format!("{}", style("0%").dim()), None => "-".to_string(), }; diff --git a/src/upload/mod.rs b/src/upload/mod.rs index b4be2739..cb4e855f 100644 --- a/src/upload/mod.rs +++ b/src/upload/mod.rs @@ -1,3 +1,4 @@ +mod benchmark_display; mod interfaces; pub mod poll_results; mod profile_archive; diff --git a/src/upload/poll_results.rs b/src/upload/poll_results.rs index 4ad51bba..1d5c8662 100644 --- a/src/upload/poll_results.rs +++ b/src/upload/poll_results.rs @@ -4,12 +4,12 @@ use std::time::Duration; use console::style; use tokio::time::{Instant, sleep}; -use crate::api_client::{ - CodSpeedAPIClient, CompareRunsResponse, CompareRunsVars, FetchLocalRunResponse, - FetchLocalRunVars, RunStatus, +use super::benchmark_display::{ + self, build_benchmark_table, build_comparison_table, build_detailed_summary, }; -use crate::cli::run::helpers::benchmark_display::{ - build_benchmark_table, build_comparison_table, build_detailed_summary, +use crate::api_client::{ + CodSpeedAPIClient, CompareRunsOutcome, CompareRunsResponse, CompareRunsVars, + FetchLocalRunResponse, FetchLocalRunVars, RunStatus, }; use crate::local_logger::{start_spinner, stop_spinner}; use crate::prelude::*; diff --git a/src/cli/run/helpers/snapshots/codspeed_runner__cli__run__helpers__benchmark_display__tests__benchmark_table_formatting.snap b/src/upload/snapshots/codspeed_runner__upload__benchmark_display__tests__benchmark_table_formatting.snap similarity index 100% rename from src/cli/run/helpers/snapshots/codspeed_runner__cli__run__helpers__benchmark_display__tests__benchmark_table_formatting.snap rename to src/upload/snapshots/codspeed_runner__upload__benchmark_display__tests__benchmark_table_formatting.snap