git_perf/
import.rs

1//! Import measurements from external test runners and benchmarks
2//!
3//! This module provides functionality to import measurements from various
4//! test and benchmark formats into git-perf's measurement storage.
5
6use anyhow::{Context, Result};
7use regex::Regex;
8use std::collections::HashMap;
9use std::io::{self, Read};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use git_perf_cli_types::ImportFormat;
13
14use crate::config;
15use crate::converters::{convert_to_measurements, ConversionOptions};
16use crate::data::MeasurementData;
17use crate::defaults;
18use crate::parsers::{CriterionJsonParser, JunitXmlParser, Parser};
19use crate::serialization::serialize_multiple;
20
21/// Options for the import command
22pub struct ImportOptions {
23    pub commit: String,
24    pub format: ImportFormat,
25    pub file: Option<String>,
26    pub prefix: Option<String>,
27    pub metadata: Vec<(String, String)>,
28    pub filter: Option<String>,
29    pub dry_run: bool,
30    pub verbose: bool,
31}
32
33/// Handle the import command
34///
35/// Reads input from stdin or file, parses it according to the specified format,
36/// converts to MeasurementData, and stores in git notes.
37pub fn handle_import(options: ImportOptions) -> Result<()> {
38    let ImportOptions {
39        commit,
40        format,
41        file,
42        prefix,
43        metadata,
44        filter,
45        dry_run,
46        verbose,
47    } = options;
48    // Read input from stdin or file
49    let input = read_input(file.as_deref())?;
50
51    // Select parser based on format
52    let parsed = match format {
53        ImportFormat::Junit => {
54            let parser = JunitXmlParser;
55            parser.parse(&input).context("Failed to parse JUnit XML")?
56        }
57        ImportFormat::CriterionJson => {
58            let parser = CriterionJsonParser;
59            parser
60                .parse(&input)
61                .context("Failed to parse criterion JSON")?
62        }
63    };
64
65    if verbose {
66        println!("Parsed {} measurements", parsed.len());
67    }
68
69    // Apply regex filter if specified
70    let filtered = if let Some(filter_pattern) = filter {
71        let regex = Regex::new(&filter_pattern).context("Invalid regex pattern for filter")?;
72
73        let original_count = parsed.len();
74        let filtered_parsed: Vec<_> = parsed
75            .into_iter()
76            .filter(|p| {
77                let name = match p {
78                    crate::parsers::ParsedMeasurement::Test(t) => &t.name,
79                    crate::parsers::ParsedMeasurement::Benchmark(b) => &b.id,
80                };
81                regex.is_match(name)
82            })
83            .collect();
84
85        if verbose {
86            println!(
87                "Filtered to {} measurements (from {}) using pattern: {}",
88                filtered_parsed.len(),
89                original_count,
90                filter_pattern
91            );
92        }
93
94        filtered_parsed
95    } else {
96        parsed
97    };
98
99    // Build conversion options
100    let timestamp = SystemTime::now()
101        .duration_since(UNIX_EPOCH)
102        .context("Failed to get system time")?
103        .as_secs_f64();
104
105    let extra_metadata: HashMap<String, String> = metadata.into_iter().collect();
106
107    let options = ConversionOptions {
108        prefix,
109        extra_metadata,
110        epoch: 0, // Will be determined per-measurement in conversion
111        timestamp,
112    };
113
114    // Convert to MeasurementData
115    let measurements = convert_to_measurements(filtered, &options);
116
117    if measurements.is_empty() {
118        println!("No measurements to import (tests without durations are skipped)");
119        return Ok(());
120    }
121
122    // Update epoch for each measurement based on config
123    let measurements: Vec<MeasurementData> = measurements
124        .into_iter()
125        .map(|mut m| {
126            m.epoch =
127                config::determine_epoch_from_config(&m.name).unwrap_or(defaults::DEFAULT_EPOCH);
128            m
129        })
130        .collect();
131
132    if verbose || dry_run {
133        println!("\nMeasurements to import:");
134        for m in &measurements {
135            println!("  {} = {} (epoch: {})", m.name, m.val, m.epoch);
136            if verbose {
137                for (k, v) in &m.key_values {
138                    println!("    {}: {}", k, v);
139                }
140            }
141        }
142        println!("\nTotal: {} measurements", measurements.len());
143    }
144
145    // Store measurements (unless dry run)
146    if dry_run {
147        println!("\n[DRY RUN] Measurements not stored");
148    } else {
149        store_measurements(&commit, &measurements)?;
150        println!("Successfully imported {} measurements", measurements.len());
151    }
152
153    Ok(())
154}
155
156/// Read input from stdin or file
157fn read_input(file: Option<&str>) -> Result<String> {
158    match file {
159        None | Some("-") => {
160            // Read from stdin
161            let mut buffer = String::new();
162            io::stdin()
163                .read_to_string(&mut buffer)
164                .context("Failed to read from stdin")?;
165            Ok(buffer)
166        }
167        Some(path) => {
168            // Read from file
169            std::fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", path))
170        }
171    }
172}
173
174/// Store multiple measurements to git notes
175///
176/// This is similar to `measurement_storage::add_multiple` but handles
177/// measurements with different names and metadata.
178fn store_measurements(commit: &str, measurements: &[MeasurementData]) -> Result<()> {
179    // Validate commit exists
180    let resolved_commit = crate::git::git_interop::resolve_committish(commit)
181        .context(format!("Failed to resolve commit '{}'", commit))?;
182
183    let serialized = serialize_multiple(measurements);
184    crate::git::git_interop::add_note_line(&resolved_commit, &serialized)?;
185    Ok(())
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::git::git_interop::walk_commits;
192    use crate::test_helpers::{dir_with_repo, hermetic_git_env};
193    use std::collections::HashMap;
194    use std::env::set_current_dir;
195    use std::io::Write;
196    use tempfile::NamedTempFile;
197
198    // Sample test data
199    const SAMPLE_JUNIT_XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
200<testsuites tests="3" failures="1" errors="0" time="3.5">
201  <testsuite name="test_binary" tests="3" failures="1" time="3.5">
202    <testcase name="test_passed" classname="module::tests" time="1.5"/>
203    <testcase name="test_failed" classname="module::tests" time="2.0">
204      <failure message="assertion failed"/>
205    </testcase>
206    <testcase name="test_skipped" classname="module::tests">
207      <skipped/>
208    </testcase>
209  </testsuite>
210</testsuites>"#;
211
212    const SAMPLE_CRITERION_JSON: &str = r#"{"reason":"benchmark-complete","id":"fibonacci/fib_10","unit":"ns","mean":{"estimate":15456.78,"lower_bound":15234.0,"upper_bound":15678.5},"median":{"estimate":15400.0,"lower_bound":15350.0,"upper_bound":15450.0},"slope":{"estimate":15420.5,"lower_bound":15380.0,"upper_bound":15460.0},"median_abs_dev":{"estimate":123.45}}
213{"reason":"benchmark-complete","id":"fibonacci/fib_20","unit":"us","mean":{"estimate":1234.56,"lower_bound":1200.0,"upper_bound":1270.0},"median":{"estimate":1220.0}}"#;
214
215    #[test]
216    fn test_read_input_from_file() {
217        let mut file = NamedTempFile::new().unwrap();
218        writeln!(file, "test content").unwrap();
219
220        let content = read_input(Some(file.path().to_str().unwrap())).unwrap();
221        assert_eq!(content.trim(), "test content");
222    }
223
224    #[test]
225    fn test_read_input_nonexistent_file() {
226        let result = read_input(Some("/nonexistent/file/path.xml"));
227        assert!(result.is_err());
228        assert!(result
229            .unwrap_err()
230            .to_string()
231            .contains("Failed to read file"));
232    }
233
234    #[test]
235    fn test_handle_import_junit_dry_run() {
236        let tempdir = dir_with_repo();
237        set_current_dir(tempdir.path()).unwrap();
238        hermetic_git_env();
239
240        let mut file = NamedTempFile::new().unwrap();
241        write!(file, "{}", SAMPLE_JUNIT_XML).unwrap();
242
243        // Get initial notes count before import
244        let commits_before = walk_commits(1).unwrap();
245        let notes_before = commits_before[0].note_lines.len();
246
247        let result = handle_import(ImportOptions {
248            commit: "HEAD".to_string(),
249            format: ImportFormat::Junit,
250            file: Some(file.path().to_str().unwrap().to_string()),
251            prefix: None,
252            metadata: vec![],
253            filter: None,
254            dry_run: true,
255            verbose: false,
256        });
257
258        assert!(result.is_ok(), "Import should succeed: {:?}", result);
259
260        // Verify no new measurements were stored (dry run)
261        let commits_after = walk_commits(1).unwrap();
262        let notes_after = commits_after[0].note_lines.len();
263
264        assert_eq!(
265            notes_after, notes_before,
266            "No new measurements should be stored in dry run (before: {}, after: {})",
267            notes_before, notes_after
268        );
269    }
270
271    #[test]
272    fn test_handle_import_junit_stores_measurements() {
273        let tempdir = dir_with_repo();
274        set_current_dir(tempdir.path()).unwrap();
275        hermetic_git_env();
276
277        let mut file = NamedTempFile::new().unwrap();
278        write!(file, "{}", SAMPLE_JUNIT_XML).unwrap();
279
280        let result = handle_import(ImportOptions {
281            commit: "HEAD".to_string(),
282            format: ImportFormat::Junit,
283            file: Some(file.path().to_str().unwrap().to_string()),
284            prefix: None,
285            metadata: vec![],
286            filter: None,
287            dry_run: false,
288            verbose: false,
289        });
290
291        assert!(result.is_ok(), "Import should succeed: {:?}", result);
292
293        // Verify measurements were stored
294        let commits = walk_commits(1).unwrap();
295        let notes = &commits[0].note_lines;
296
297        // Should have 2 measurements (passed and failed tests with durations)
298        // Skipped test has no time attribute so it's not imported
299        assert!(
300            notes.len() >= 2,
301            "Should have at least 2 measurement lines, got: {}",
302            notes.len()
303        );
304
305        // Verify measurement names
306        let notes_text = notes.join("\n");
307        assert!(
308            notes_text.contains("test::test_passed"),
309            "Should contain test_passed measurement"
310        );
311        assert!(
312            notes_text.contains("test::test_failed"),
313            "Should contain test_failed measurement"
314        );
315
316        // Skipped test should not be stored (no time attribute means no duration)
317        assert!(
318            !notes_text.contains("test::test_skipped"),
319            "Should not contain skipped test (no time attribute = no duration)"
320        );
321    }
322
323    #[test]
324    fn test_handle_import_junit_with_prefix() {
325        let tempdir = dir_with_repo();
326        set_current_dir(tempdir.path()).unwrap();
327        hermetic_git_env();
328
329        let mut file = NamedTempFile::new().unwrap();
330        write!(file, "{}", SAMPLE_JUNIT_XML).unwrap();
331
332        let result = handle_import(ImportOptions {
333            commit: "HEAD".to_string(),
334            format: ImportFormat::Junit,
335            file: Some(file.path().to_str().unwrap().to_string()),
336            prefix: Some("ci".to_string()),
337            metadata: vec![],
338            filter: None,
339            dry_run: false,
340            verbose: false,
341        });
342
343        assert!(result.is_ok(), "Import with prefix should succeed");
344
345        let commits = walk_commits(1).unwrap();
346        let notes_text = commits[0].note_lines.join("\n");
347
348        assert!(
349            notes_text.contains("ci::test::test_passed"),
350            "Should contain prefixed measurement name"
351        );
352    }
353
354    #[test]
355    fn test_handle_import_junit_with_metadata() {
356        let tempdir = dir_with_repo();
357        set_current_dir(tempdir.path()).unwrap();
358        hermetic_git_env();
359
360        let mut file = NamedTempFile::new().unwrap();
361        write!(file, "{}", SAMPLE_JUNIT_XML).unwrap();
362
363        let result = handle_import(ImportOptions {
364            commit: "HEAD".to_string(),
365            format: ImportFormat::Junit,
366            file: Some(file.path().to_str().unwrap().to_string()),
367            prefix: None,
368            metadata: vec![
369                ("ci".to_string(), "true".to_string()),
370                ("branch".to_string(), "main".to_string()),
371            ],
372            filter: None,
373            dry_run: false,
374            verbose: false,
375        });
376
377        assert!(result.is_ok(), "Import with metadata should succeed");
378
379        let commits = walk_commits(1).unwrap();
380        let notes_text = commits[0].note_lines.join("\n");
381
382        // Metadata should be included in the stored measurements
383        assert!(
384            notes_text.contains("ci") && notes_text.contains("true"),
385            "Should contain ci metadata"
386        );
387        assert!(
388            notes_text.contains("branch") && notes_text.contains("main"),
389            "Should contain branch metadata"
390        );
391    }
392
393    #[test]
394    fn test_handle_import_junit_with_filter() {
395        let tempdir = dir_with_repo();
396        set_current_dir(tempdir.path()).unwrap();
397        hermetic_git_env();
398
399        let mut file = NamedTempFile::new().unwrap();
400        write!(file, "{}", SAMPLE_JUNIT_XML).unwrap();
401
402        // Filter to only import tests matching "passed"
403        let result = handle_import(ImportOptions {
404            commit: "HEAD".to_string(),
405            format: ImportFormat::Junit,
406            file: Some(file.path().to_str().unwrap().to_string()),
407            prefix: None,
408            metadata: vec![],
409            filter: Some("passed".to_string()),
410            dry_run: false,
411            verbose: false,
412        });
413
414        assert!(result.is_ok(), "Import with filter should succeed");
415
416        let commits = walk_commits(1).unwrap();
417        let notes_text = commits[0].note_lines.join("\n");
418
419        assert!(
420            notes_text.contains("test::test_passed"),
421            "Should contain filtered test_passed"
422        );
423        assert!(
424            !notes_text.contains("test::test_failed"),
425            "Should not contain filtered out test_failed"
426        );
427    }
428
429    #[test]
430    fn test_handle_import_criterion_json() {
431        let tempdir = dir_with_repo();
432        set_current_dir(tempdir.path()).unwrap();
433        hermetic_git_env();
434
435        let mut file = NamedTempFile::new().unwrap();
436        write!(file, "{}", SAMPLE_CRITERION_JSON).unwrap();
437
438        let result = handle_import(ImportOptions {
439            commit: "HEAD".to_string(),
440            format: ImportFormat::CriterionJson,
441            file: Some(file.path().to_str().unwrap().to_string()),
442            prefix: None,
443            metadata: vec![],
444            filter: None,
445            dry_run: false,
446            verbose: false,
447        });
448
449        assert!(
450            result.is_ok(),
451            "Criterion import should succeed: {:?}",
452            result
453        );
454
455        let commits = walk_commits(1).unwrap();
456        let notes_text = commits[0].note_lines.join("\n");
457
458        // Should have multiple statistics per benchmark
459        assert!(
460            notes_text.contains("bench::fibonacci/fib_10::mean"),
461            "Should contain mean statistic"
462        );
463        assert!(
464            notes_text.contains("bench::fibonacci/fib_10::median"),
465            "Should contain median statistic"
466        );
467        assert!(
468            notes_text.contains("bench::fibonacci/fib_10::slope"),
469            "Should contain slope statistic"
470        );
471
472        // Check unit conversion (us -> ns)
473        assert!(
474            notes_text.contains("bench::fibonacci/fib_20::mean"),
475            "Should contain second benchmark"
476        );
477    }
478
479    #[test]
480    fn test_handle_import_invalid_format() {
481        let tempdir = dir_with_repo();
482        set_current_dir(tempdir.path()).unwrap();
483        hermetic_git_env();
484
485        let mut file = NamedTempFile::new().unwrap();
486        write!(file, "invalid xml content").unwrap();
487
488        let result = handle_import(ImportOptions {
489            commit: "HEAD".to_string(),
490            format: ImportFormat::Junit,
491            file: Some(file.path().to_str().unwrap().to_string()),
492            prefix: None,
493            metadata: vec![],
494            filter: None,
495            dry_run: false,
496            verbose: false,
497        });
498
499        assert!(result.is_err(), "Should fail with invalid XML");
500        assert!(
501            result.unwrap_err().to_string().contains("parse"),
502            "Error should mention parsing failure"
503        );
504    }
505
506    #[test]
507    fn test_handle_import_empty_file() {
508        let tempdir = dir_with_repo();
509        set_current_dir(tempdir.path()).unwrap();
510        hermetic_git_env();
511
512        let mut file = NamedTempFile::new().unwrap();
513        write!(
514            file,
515            r#"<?xml version="1.0"?><testsuites tests="0"></testsuites>"#
516        )
517        .unwrap();
518
519        // Get initial notes count before import
520        let commits_before = walk_commits(1).unwrap();
521        let notes_before = commits_before[0].note_lines.len();
522
523        let result = handle_import(ImportOptions {
524            commit: "HEAD".to_string(),
525            format: ImportFormat::Junit,
526            file: Some(file.path().to_str().unwrap().to_string()),
527            prefix: None,
528            metadata: vec![],
529            filter: None,
530            dry_run: false,
531            verbose: false,
532        });
533
534        // Should succeed but import no measurements
535        assert!(result.is_ok(), "Should handle empty test results");
536
537        // Verify no new measurements were added
538        let commits_after = walk_commits(1).unwrap();
539        let notes_after = commits_after[0].note_lines.len();
540
541        assert_eq!(
542            notes_after, notes_before,
543            "Should not store any new measurements for empty results (before: {}, after: {})",
544            notes_before, notes_after
545        );
546    }
547
548    #[test]
549    fn test_handle_import_invalid_regex_filter() {
550        let tempdir = dir_with_repo();
551        set_current_dir(tempdir.path()).unwrap();
552        hermetic_git_env();
553
554        let mut file = NamedTempFile::new().unwrap();
555        write!(file, "{}", SAMPLE_JUNIT_XML).unwrap();
556
557        let result = handle_import(ImportOptions {
558            commit: "HEAD".to_string(),
559            format: ImportFormat::Junit,
560            file: Some(file.path().to_str().unwrap().to_string()),
561            prefix: None,
562            metadata: vec![],
563            filter: Some("[invalid(regex".to_string()),
564            dry_run: false,
565            verbose: false,
566        });
567
568        assert!(result.is_err(), "Should fail with invalid regex");
569        assert!(
570            result.unwrap_err().to_string().contains("regex"),
571            "Error should mention regex"
572        );
573    }
574
575    #[test]
576    fn test_store_measurements_integration() {
577        let tempdir = dir_with_repo();
578        set_current_dir(tempdir.path()).unwrap();
579        hermetic_git_env();
580
581        // Create test measurements
582        let measurements = vec![
583            MeasurementData {
584                epoch: 0,
585                name: "test::integration_test".to_string(),
586                timestamp: 1234567890.0,
587                val: 1500000000.0, // 1.5 seconds in nanoseconds
588                key_values: {
589                    let mut map = HashMap::new();
590                    map.insert("type".to_string(), "test".to_string());
591                    map.insert("status".to_string(), "passed".to_string());
592                    map
593                },
594            },
595            MeasurementData {
596                epoch: 0,
597                name: "bench::my_bench::mean".to_string(),
598                timestamp: 1234567890.0,
599                val: 15000.0, // 15000 nanoseconds
600                key_values: {
601                    let mut map = HashMap::new();
602                    map.insert("type".to_string(), "bench".to_string());
603                    map.insert("statistic".to_string(), "mean".to_string());
604                    map
605                },
606            },
607        ];
608
609        let result = store_measurements("HEAD", &measurements);
610        assert!(
611            result.is_ok(),
612            "Storing measurements should succeed: {:?}",
613            result
614        );
615
616        // Verify measurements were stored
617        let commits = walk_commits(1).unwrap();
618        let notes = &commits[0].note_lines;
619
620        assert!(
621            notes.len() >= 2,
622            "Should have stored 2 measurements, got: {}",
623            notes.len()
624        );
625
626        let notes_text = notes.join("\n");
627        assert!(
628            notes_text.contains("test::integration_test"),
629            "Should contain test measurement"
630        );
631        assert!(
632            notes_text.contains("bench::my_bench::mean"),
633            "Should contain benchmark measurement"
634        );
635    }
636}