Skip to main content

cargo_mate/tools/
bench_diff.rs

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(&current_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", &current_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}