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