Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ jobs:
test:
name: "cargo test --workspace #${{ matrix.platform }} ${{ matrix.rust_version }}"
runs-on: ${{ matrix.platform }}
permissions:
checks: write
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
Expand Down Expand Up @@ -72,6 +75,10 @@ jobs:
env:
RUSTFLAGS: "-C prefer-dynamic"
RUST_BACKTRACE: full
- name: Add file attributes to JUnit XML
if: success() || failure()
shell: bash
run: cargo run --bin add_junit_file_attributes -- target/nextest/ci/junit.xml
- name: Report Test Results
if: success() || failure()
uses: mikepenz/action-junit-report@db71d41eb79864e25ab0337e395c352e84523afe # 4.3.1
Expand Down
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions tools/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ bench = false

[dependencies]
anyhow = "1.0"
cargo_metadata = "0.18"
clap = { version = "4.0", features = ["derive"] }
colored = "2"
quick-xml = "0.37"
regex = "1"
wait-timeout = "0.2"

Expand All @@ -28,3 +30,7 @@ bench = false
[[bin]]
name = "ffi_test"
bench = false

[[bin]]
name = "add_junit_file_attributes"
bench = false
1 change: 1 addition & 0 deletions tools/docker/Dockerfile.build
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ RUN echo \
tools/sidecar_mockgen/src/bin/sidecar_mockgen.rs \
tools/src/bin/dedup_headers.rs \
tools/src/bin/ffi_test.rs \
tools/src/bin/add_junit_file_attributes.rs \
libdd-trace-normalization/benches/normalization_utils.rs \
libdd-trace-obfuscation/benches/trace_obfuscation.rs \
libdd-trace-utils/benches/deserialization.rs \
Expand Down
299 changes: 299 additions & 0 deletions tools/src/bin/add_junit_file_attributes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
// Copyright 2021-Present Datadog, Inc. https://www.datadoghq.com/
// SPDX-License-Identifier: Apache-2.0

//! Add file attributes to JUnit XML test reports
//!
//! This tool enriches JUnit XML files generated by cargo-nextest with `file` attributes
//! on `<testcase>` elements. It uses cargo metadata to map Rust test paths to source files.

use anyhow::{anyhow, Context, Result};
use cargo_metadata::{CargoOpt, MetadataCommand};
use clap::Parser;
use quick_xml::events::{BytesStart, Event};
use quick_xml::{Reader, Writer};
use std::collections::HashMap;
use std::fs;
use std::io::Cursor;
use std::path::PathBuf;

#[derive(Parser, Debug)]
#[command(name = "add_junit_file_attributes")]
#[command(about = "Add file attributes to JUnit XML test reports using cargo metadata")]
struct Args {
/// Input JUnit XML file
input: PathBuf,

/// Output JUnit XML file (defaults to overwriting input)
#[arg(short, long)]
output: Option<PathBuf>,

/// Path to Cargo.toml (defaults to finding workspace root)
#[arg(short = 'C', long)]
manifest_path: Option<PathBuf>,
}

/// Information about a target from cargo metadata
#[derive(Debug, Clone)]
struct TargetInfo {
src_path: PathBuf,
kind: String,
}

/// Map from (package_name, target_name) to TargetInfo
type TargetMap = HashMap<(String, String), TargetInfo>;

/// Map from package_name to lib target info (for packages where lib name differs from package name)
type LibMap = HashMap<String, TargetInfo>;

fn build_target_map(
manifest_path: Option<&std::path::Path>,
) -> Result<(TargetMap, LibMap, PathBuf)> {
let mut cmd = MetadataCommand::new();
cmd.features(CargoOpt::AllFeatures);
cmd.no_deps();

if let Some(path) = manifest_path {
cmd.manifest_path(path);
}

let metadata = cmd.exec().context("failed to run cargo metadata")?;
let workspace_root = metadata.workspace_root.clone().into_std_path_buf();

let mut target_map = HashMap::new();
let mut lib_map = HashMap::new();

for package in metadata.packages {
// Normalize package name (replace - with _)
let package_name = package.name.replace('-', "_");

for target in package.targets {
let kind = target
.kind
.first()
.map(|s| s.as_str())
.unwrap_or("unknown");

// Only interested in lib-like (lib, proc-macro) and test targets
let is_lib_like = kind == "lib" || kind == "proc-macro";
if !is_lib_like && kind != "test" {
continue;
}

let src_path = target.src_path.into_std_path_buf();
let target_name = target.name.replace('-', "_");

let info = TargetInfo {
src_path,
kind: kind.to_string(),
};

// For lib-like targets, also store a mapping from package_name to the lib info
if is_lib_like {
lib_map.insert(package_name.clone(), info.clone());
}

target_map.insert((package_name.clone(), target_name), info);
}
}

Ok((target_map, lib_map, workspace_root))
}

fn main() -> Result<()> {
let args = Args::parse();

// Build target map from cargo metadata
let (target_map, lib_map, workspace_root) = build_target_map(args.manifest_path.as_deref())?;

// Read input file
let input = fs::read_to_string(&args.input)
.with_context(|| format!("failed to read {}", args.input.display()))?;

// Process the XML
let output = process_junit_xml(&input, &target_map, &lib_map, &workspace_root)?;

// Write output
let output_path = args.output.as_ref().unwrap_or(&args.input);
fs::write(output_path, output)
.with_context(|| format!("failed to write {}", output_path.display()))?;

Ok(())
}

/// Process the JUnit XML and add file attributes to testcase elements
fn process_junit_xml(
xml: &str,
target_map: &TargetMap,
lib_map: &LibMap,
workspace_root: &PathBuf,
) -> Result<String> {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(false);

let mut writer = Writer::new(Cursor::new(Vec::new()));

loop {
match reader.read_event() {
Ok(Event::Start(ref e)) => {
if e.name().as_ref() == b"testcase" {
let elem = add_file_to_testcase(e, target_map, lib_map, workspace_root);
writer.write_event(Event::Start(elem))?;
} else {
writer.write_event(Event::Start(e.clone()))?;
}
}
Ok(Event::Empty(ref e)) => {
if e.name().as_ref() == b"testcase" {
let elem = add_file_to_testcase(e, target_map, lib_map, workspace_root);
writer.write_event(Event::Empty(elem))?;
} else {
writer.write_event(Event::Empty(e.clone()))?;
}
}
Ok(Event::Eof) => break,
Ok(e) => writer.write_event(e)?,
Err(e) => return Err(anyhow!("XML parse error: {}", e)),
}
}

let result = writer.into_inner().into_inner();
String::from_utf8(result).context("output is not valid UTF-8")
}

/// Add file attribute to a testcase element
fn add_file_to_testcase(
elem: &BytesStart,
target_map: &TargetMap,
lib_map: &LibMap,
workspace_root: &PathBuf,
) -> BytesStart<'static> {
let mut testcase_name = None;
let mut classname = None;

for attr in elem.attributes().filter_map(|a| a.ok()) {
match attr.key.as_ref() {
b"name" => testcase_name = Some(String::from_utf8_lossy(&attr.value).to_string()),
b"classname" => classname = Some(String::from_utf8_lossy(&attr.value).to_string()),
_ => {}
}
}

let file_path = resolve_file_path(
classname.as_deref(),
testcase_name.as_deref(),
target_map,
lib_map,
workspace_root,
);

// Clone the element and add file attribute if we found a path
let mut new_elem = elem.to_owned();
if let Some(path) = file_path {
new_elem.push_attribute(("file", path.as_str()));
}

new_elem
}

/// Resolve a file path from classname using cargo metadata
fn resolve_file_path(
classname: Option<&str>,
testcase_name: Option<&str>,
target_map: &TargetMap,
lib_map: &LibMap,
workspace_root: &PathBuf,
) -> Option<String> {
let classname = classname?;

// Split classname into parts: e.g., "libdd-trace-utils::test_send_data" -> ["libdd-trace-utils", "test_send_data"]
let parts: Vec<&str> = classname.split("::").collect();
if parts.is_empty() {
return None;
}

// Normalize: replace hyphens with underscores to match cargo metadata normalization
let package_name = parts[0].replace('-', "_");

// For integration tests, classname is "package::target_name"
if parts.len() >= 2 {
let target_name = parts[1].replace('-', "_");
if let Some(info) = target_map.get(&(package_name.clone(), target_name)) {
if info.kind == "test" {
let relative = info
.src_path
.strip_prefix(workspace_root)
.unwrap_or(&info.src_path);
return Some(relative.to_string_lossy().to_string());
}
}
}

// For unit tests, classname is just "package" - need to derive file from test name
// First try the target map with matching package/lib name, then fall back to lib_map
let lib_info = target_map
.get(&(package_name.clone(), package_name.clone()))
.or_else(|| lib_map.get(&package_name));

if let Some(info) = lib_info {
// Get the src directory (parent of lib.rs)
let src_dir = info.src_path.parent()?;

// Try to find the module file from the test name
// Test name is like "trace_utils::tests::test_compute_top_level"
// We want to find src/trace_utils.rs or src/trace_utils/mod.rs
if let Some(test_name) = testcase_name {
if let Some(file_path) = resolve_module_file(test_name, src_dir, workspace_root) {
return Some(file_path);
}
}

// Fallback to lib.rs
let relative = info
.src_path
.strip_prefix(workspace_root)
.unwrap_or(&info.src_path);
return Some(relative.to_string_lossy().to_string());
}

None
}

/// Try to find the source file for a unit test based on its module path
fn resolve_module_file(
test_name: &str,
src_dir: &std::path::Path,
workspace_root: &PathBuf,
) -> Option<String> {
// Test name is like "trace_utils::tests::test_fn" or "span::trace_utils::tests::test_fn"
// Extract module parts before "tests"
let parts: Vec<&str> = test_name.split("::").collect();

// Find the "tests" module and take everything before it
let module_parts: Vec<&str> = parts
.iter()
.take_while(|&&p| p != "tests")
.copied()
.collect();

if module_parts.is_empty() {
return None;
}

// Try module_path.rs (e.g., src/trace_utils.rs or src/span/trace_utils.rs)
let module_file = src_dir.join(format!("{}.rs", module_parts.join("/")));
if module_file.exists() {
let relative = module_file
.strip_prefix(workspace_root)
.unwrap_or(&module_file);
return Some(relative.to_string_lossy().to_string());
}

// Try module_path/mod.rs (e.g., src/trace_utils/mod.rs)
let mod_file = src_dir.join(format!("{}/mod.rs", module_parts.join("/")));
if mod_file.exists() {
let relative = mod_file.strip_prefix(workspace_root).unwrap_or(&mod_file);
return Some(relative.to_string_lossy().to_string());
}

None
}
Loading