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::parsers::{CriterionJsonParser, JunitXmlParser, Parser};
18use crate::serialization::serialize_multiple;
19
20pub 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 let input = read_input(file.as_deref())?;
35
36 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 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 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, timestamp,
97 };
98
99 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 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 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
140fn read_input(file: Option<&str>) -> Result<String> {
142 match file {
143 None | Some("-") => {
144 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 std::fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", path))
154 }
155 }
156}
157
158fn 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 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 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, false, );
236
237 assert!(result.is_ok(), "Import should succeed: {:?}", result);
238
239 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, false,
267 );
268
269 assert!(result.is_ok(), "Import should succeed: {:?}", result);
270
271 let commits = walk_commits(1).unwrap();
273 let notes = &commits[0].1;
274
275 assert!(
278 notes.len() >= 2,
279 "Should have at least 2 measurement lines, got: {}",
280 notes.len()
281 );
282
283 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 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()), 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 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 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()), 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 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 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 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 assert!(result.is_ok(), "Should handle empty test results");
508
509 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()), 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 let measurements = vec![
554 MeasurementData {
555 epoch: 0,
556 name: "test::integration_test".to_string(),
557 timestamp: 1234567890.0,
558 val: 1500000000.0, 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, 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 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}