1use 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
21pub 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
33pub 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 let input = read_input(file.as_deref())?;
50
51 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 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 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, timestamp,
112 };
113
114 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 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 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
156fn read_input(file: Option<&str>) -> Result<String> {
158 match file {
159 None | Some("-") => {
160 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 std::fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", path))
170 }
171 }
172}
173
174fn store_measurements(commit: &str, measurements: &[MeasurementData]) -> Result<()> {
179 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 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 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 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 let commits = walk_commits(1).unwrap();
295 let notes = &commits[0].note_lines;
296
297 assert!(
300 notes.len() >= 2,
301 "Should have at least 2 measurement lines, got: {}",
302 notes.len()
303 );
304
305 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 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 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 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 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 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 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 assert!(result.is_ok(), "Should handle empty test results");
536
537 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 let measurements = vec![
583 MeasurementData {
584 epoch: 0,
585 name: "test::integration_test".to_string(),
586 timestamp: 1234567890.0,
587 val: 1500000000.0, 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, 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 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}