1use super::{Tool, Result, ToolError, common_options, parse_output_format, OutputFormat};
2use clap::{Arg, ArgMatches, Command};
3use colored::*;
4use std::path::Path;
5use std::process::Command as ProcessCommand;
6use std::collections::HashMap;
7use serde::{Deserialize, Serialize};
8#[derive(Debug, Clone)]
9pub struct BenchDiffTool;
10#[derive(Debug, Deserialize, Serialize)]
11struct BenchmarkResult {
12 name: String,
13 time: String,
14 throughput: Option<String>,
15}
16#[derive(Debug, serde::Serialize)]
17struct BenchmarkComparison {
18 name: String,
19 before_time: f64,
20 after_time: f64,
21 improvement: f64,
22 regression: bool,
23}
24impl BenchDiffTool {
25 pub fn new() -> Self {
26 Self
27 }
28 fn parse_time_to_ns(&self, time_str: &str) -> Result<f64> {
29 let parts: Vec<&str> = time_str.split_whitespace().collect();
30 if parts.is_empty() {
31 return Err(ToolError::ExecutionFailed("Invalid time format".to_string()));
32 }
33 let (value_str, unit) = if parts.len() >= 2 {
34 (parts[0], parts[1])
35 } else {
36 (time_str, "ns")
37 };
38 let value: f64 = value_str
39 .parse()
40 .map_err(|_| ToolError::ExecutionFailed(
41 "Cannot parse time value".to_string(),
42 ))?;
43 let multiplier = match unit {
44 "ns" | "ns/iter" => 1.0,
45 "ยตs" | "ยตs/iter" => 1_000.0,
46 "ms" | "ms/iter" => 1_000_000.0,
47 "s" | "s/iter" => 1_000_000_000.0,
48 _ => 1.0,
49 };
50 Ok(value * multiplier)
51 }
52 fn run_benchmark(&self, commit: &str) -> Result<Vec<BenchmarkResult>> {
53 println!("๐ Running benchmarks for commit: {}", commit.yellow());
54 let checkout_result = ProcessCommand::new("git")
55 .args(&["checkout", commit])
56 .output()
57 .map_err(|e| ToolError::ExecutionFailed(
58 format!("Failed to checkout commit: {}", e),
59 ))?;
60 if !checkout_result.status.success() {
61 return Err(ToolError::ExecutionFailed("Git checkout failed".to_string()));
62 }
63 let bench_result = ProcessCommand::new("cargo")
64 .args(&["bench", "--message-format", "json"])
65 .output()
66 .map_err(|e| ToolError::ExecutionFailed(
67 format!("Failed to run cargo bench: {}", e),
68 ))?;
69 if !bench_result.status.success() {
70 return Err(ToolError::ExecutionFailed("Cargo bench failed".to_string()));
71 }
72 let output = String::from_utf8_lossy(&bench_result.stdout);
73 let mut results = Vec::new();
74 for line in output.lines() {
75 if line.contains("test ") && line.contains("time:") {
76 if let Some(test_name) = self.extract_test_name(line) {
77 if let Some(time_str) = self.extract_time(line) {
78 results
79 .push(BenchmarkResult {
80 name: test_name,
81 time: time_str,
82 throughput: None,
83 });
84 }
85 }
86 }
87 }
88 Ok(results)
89 }
90 fn extract_test_name(&self, line: &str) -> Option<String> {
91 if let Some(start) = line.find("test ") {
92 let after_test = &line[start + 5..];
93 if let Some(end) = after_test.find(" ...") {
94 return Some(after_test[..end].to_string());
95 }
96 }
97 None
98 }
99 fn extract_time(&self, line: &str) -> Option<String> {
100 if let Some(time_start) = line.find("time: [") {
101 let after_time = &line[time_start + 7..];
102 if let Some(end) = after_time.find(']') {
103 return Some(after_time[..end].to_string());
104 }
105 }
106 None
107 }
108 fn compare_benchmarks(
109 &self,
110 before: &[BenchmarkResult],
111 after: &[BenchmarkResult],
112 ) -> Vec<BenchmarkComparison> {
113 let mut comparisons = Vec::new();
114 let mut before_map: HashMap<String, f64> = HashMap::new();
115 for result in before {
116 if let Ok(ns) = self.parse_time_to_ns(&result.time) {
117 before_map.insert(result.name.clone(), ns);
118 }
119 }
120 for result in after {
121 if let Ok(after_ns) = self.parse_time_to_ns(&result.time) {
122 if let Some(before_ns) = before_map.get(&result.name) {
123 let improvement = ((before_ns - after_ns) / before_ns) * 100.0;
124 comparisons
125 .push(BenchmarkComparison {
126 name: result.name.clone(),
127 before_time: *before_ns,
128 after_time: after_ns,
129 improvement,
130 regression: improvement < 0.0,
131 });
132 }
133 }
134 }
135 comparisons.sort_by(|a, b| a.name.cmp(&b.name));
136 comparisons
137 }
138 fn format_time(&self, ns: f64) -> String {
139 if ns >= 1_000_000_000.0 {
140 format!("{:.2}s", ns / 1_000_000_000.0)
141 } else if ns >= 1_000_000.0 {
142 format!("{:.2}ms", ns / 1_000_000.0)
143 } else if ns >= 1_000.0 {
144 format!("{:.2}ยตs", ns / 1_000.0)
145 } else {
146 format!("{:.2}ns", ns)
147 }
148 }
149 fn display_comparison(
150 &self,
151 comparisons: &[BenchmarkComparison],
152 format: OutputFormat,
153 ) {
154 match format {
155 OutputFormat::Json => {
156 println!("{}", serde_json::to_string_pretty(comparisons).unwrap());
157 }
158 OutputFormat::Table => {
159 println!(
160 "{:<40} {:<15} {:<15} {:<12}", "Benchmark", "Before", "After",
161 "Change"
162 );
163 println!("{}", "โ".repeat(85));
164 for comp in comparisons {
165 let change_color = if comp.regression {
166 comp.improvement.to_string().red()
167 } else {
168 comp.improvement.to_string().green()
169 };
170 println!(
171 "{:<40} {:<15} {:<15} {:>+6.2}%", comp.name, self
172 .format_time(comp.before_time), self.format_time(comp
173 .after_time), change_color
174 );
175 }
176 }
177 OutputFormat::Human => {
178 println!("{}", "๐ Benchmark Comparison Results".bold().blue());
179 println!("{}", "โ".repeat(60).blue());
180 let mut improved = 0;
181 let mut regressed = 0;
182 for comp in comparisons {
183 let status = if comp.regression {
184 regressed += 1;
185 "๐ REGRESSION".red().bold()
186 } else {
187 improved += 1;
188 "๐ IMPROVED".green().bold()
189 };
190 println!("{} {}", status, comp.name.bold());
191 println!(
192 " Before: {} | After: {} | Change: {:>+6.2}%", self
193 .format_time(comp.before_time).cyan(), self.format_time(comp
194 .after_time).cyan(), comp.improvement
195 );
196 println!();
197 }
198 println!("{}", "Summary:".bold());
199 println!(" {} Improved", format!("{} โ
", improved) .green());
200 println!(" {} Regressed", format!("{} โ", regressed) .red());
201 }
202 }
203 }
204}
205impl Tool for BenchDiffTool {
206 fn name(&self) -> &'static str {
207 "bench-diff"
208 }
209 fn description(&self) -> &'static str {
210 "Compare benchmark results between commits"
211 }
212 fn command(&self) -> Command {
213 Command::new(self.name())
214 .about(self.description())
215 .long_about(
216 "Compare cargo bench results between two commits to identify performance changes",
217 )
218 .args(
219 &[
220 Arg::new("from")
221 .long("from")
222 .short('f')
223 .help("Starting commit (default: HEAD~1)")
224 .default_value("HEAD~1"),
225 Arg::new("to")
226 .long("to")
227 .short('t')
228 .help("Ending commit (default: HEAD)")
229 .default_value("HEAD"),
230 Arg::new("threshold")
231 .long("threshold")
232 .help("Minimum percentage change to report (default: 5.0)")
233 .default_value("5.0"),
234 Arg::new("save")
235 .long("save")
236 .help("Save results to .cargo-mate/benchmarks/"),
237 ],
238 )
239 .args(&common_options())
240 }
241 fn execute(&self, matches: &ArgMatches) -> Result<()> {
242 let from_commit = matches.get_one::<String>("from").unwrap();
243 let to_commit = matches.get_one::<String>("to").unwrap();
244 let threshold: f64 = matches
245 .get_one::<String>("threshold")
246 .unwrap()
247 .parse()
248 .map_err(|_| ToolError::InvalidArguments(
249 "Invalid threshold value".to_string(),
250 ))?;
251 let save_results = matches.get_flag("save");
252 let output_format = parse_output_format(matches);
253 let verbose = matches.get_flag("verbose");
254 if !Path::new(".git").exists() {
255 return Err(
256 ToolError::ExecutionFailed("Not in a git repository".to_string()),
257 );
258 }
259 println!(
260 "๐ {} - Comparing benchmark performance", "CargoMate BenchDiff".bold()
261 .blue()
262 );
263 println!(
264 " From: {} | To: {} | Threshold: ยฑ{}%", from_commit, to_commit, threshold
265 );
266 println!();
267 let current_commit = ProcessCommand::new("git")
268 .args(&["rev-parse", "HEAD"])
269 .output()
270 .map_err(|e| ToolError::ExecutionFailed(
271 format!("Failed to get current commit: {}", e),
272 ))?;
273 let current_commit = String::from_utf8_lossy(¤t_commit.stdout)
274 .trim()
275 .to_string();
276 if verbose {
277 println!("๐ Current commit: {}", current_commit.dimmed());
278 }
279 let before_results = self.run_benchmark(from_commit)?;
280 let after_results = self.run_benchmark(to_commit)?;
281 ProcessCommand::new("git")
282 .args(&["checkout", ¤t_commit])
283 .output()
284 .map_err(|e| ToolError::ExecutionFailed(
285 format!("Failed to restore commit: {}", e),
286 ))?;
287 if verbose {
288 println!("โ
Restored to original commit: {}", current_commit.dimmed());
289 }
290 let comparisons = self.compare_benchmarks(&before_results, &after_results);
291 let significant_changes: Vec<_> = comparisons
292 .into_iter()
293 .filter(|comp| comp.improvement.abs() >= threshold)
294 .collect();
295 if significant_changes.is_empty() {
296 println!(
297 "๐ No significant changes detected (threshold: ยฑ{}%)", threshold
298 );
299 } else {
300 self.display_comparison(&significant_changes, output_format);
301 }
302 if save_results {
303 self.save_results(&significant_changes, from_commit, to_commit)?;
304 }
305 Ok(())
306 }
307}
308impl BenchDiffTool {
309 fn save_results(
310 &self,
311 comparisons: &[BenchmarkComparison],
312 from: &str,
313 to: &str,
314 ) -> Result<()> {
315 use std::fs;
316 let cargo_mate_dir = Path::new(".cargo-mate");
317 let benchmarks_dir = cargo_mate_dir.join("benchmarks");
318 fs::create_dir_all(&benchmarks_dir)
319 .map_err(|e| ToolError::ExecutionFailed(
320 format!("Failed to create benchmarks dir: {}", e),
321 ))?;
322 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
323 let filename = format!("bench_diff_{}_to_{}_{}.json", from, to, timestamp);
324 let filepath = benchmarks_dir.join(filename);
325 let results = serde_json::json!(
326 { "from_commit" : from, "to_commit" : to, "timestamp" : chrono::Utc::now()
327 .to_rfc3339(), "comparisons" : comparisons, "threshold" : 5.0 }
328 );
329 fs::write(&filepath, serde_json::to_string_pretty(&results).unwrap())
330 .map_err(|e| ToolError::ExecutionFailed(
331 format!("Failed to save results: {}", e),
332 ))?;
333 println!("๐พ Results saved to: {}", filepath.display().to_string().cyan());
334 Ok(())
335 }
336}
337impl Default for BenchDiffTool {
338 fn default() -> Self {
339 Self::new()
340 }
341}