use {
crate::{
debugger::source::SourceResolver,
flamegraph::trace::{find_unstripped_binary, REGS_ENTRY_SIZE},
},
anyhow::{anyhow, Context, Result},
std::{
collections::{BTreeMap, BTreeSet},
fs,
io::Write,
path::{Path, PathBuf},
},
};
pub fn generate_lcov(
trace_dir: &Path,
programs: &BTreeMap<String, PathBuf>,
manifest_dir: Option<&Path>,
output: &Path,
) -> Result<()> {
let pc_sets = collect_pcs_from_traces(trace_dir)?;
if pc_sets.is_empty() {
return Err(anyhow!("no trace data found in {}", trace_dir.display()));
}
eprintln!("found {} program(s) in traces", pc_sets.len());
let mut line_hits: BTreeMap<PathBuf, BTreeMap<u32, u64>> = BTreeMap::new();
for (program_id, pcs) in &pc_sets {
let deployed = match programs.get(program_id) {
Some(p) => p,
None => {
eprintln!("warning: no .so found for program {program_id}, skipping");
continue;
}
};
let dwarf_path = find_unstripped_binary(deployed, manifest_dir)
.unwrap_or_else(|| deployed.to_path_buf());
let resolver = SourceResolver::from_elf_path(&dwarf_path);
if resolver.is_empty() {
eprintln!(
"warning: no DWARF in {} — rebuild with CARGO_PROFILE_RELEASE_DEBUG=2",
dwarf_path.display()
);
continue;
}
for loc in resolver.executable_lines() {
if let Some(path) = resolve_source_path(&loc.file, manifest_dir) {
line_hits
.entry(path)
.or_default()
.entry(loc.line)
.or_insert(0);
}
}
let mut resolved_count = 0u64;
for &pc in pcs {
let frames = resolver.resolve_frames(pc);
if !frames.is_empty() {
resolved_count += 1;
}
for loc in frames {
if let Some(path) = resolve_source_path(&loc.file, manifest_dir) {
*line_hits
.entry(path)
.or_default()
.entry(loc.line)
.or_insert(0) += 1;
}
}
}
eprintln!(
" {} — {} unique PCs, {} resolved to source",
dwarf_path.file_name().unwrap_or_default().to_string_lossy(),
pcs.len(),
resolved_count,
);
}
if line_hits.is_empty() {
return Err(anyhow!(
"no source lines resolved from trace data in {}",
trace_dir.display()
));
}
let summary = write_lcov_records(&line_hits, output)?;
eprintln!(
" {} source files, {}/{} lines hit",
summary.files, summary.hit_lines, summary.total_lines
);
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
struct LcovSummary {
files: usize,
total_lines: usize,
hit_lines: usize,
}
fn write_lcov_records(
line_hits: &BTreeMap<PathBuf, BTreeMap<u32, u64>>,
output: &Path,
) -> Result<LcovSummary> {
let mut out =
fs::File::create(output).with_context(|| format!("create {}", output.display()))?;
let mut summary = LcovSummary {
files: line_hits.len(),
total_lines: 0,
hit_lines: 0,
};
for (file, lines) in line_hits {
writeln!(out, "SF:{}", file.display())?;
for (&line, &hits) in lines {
writeln!(out, "DA:{line},{hits}")?;
}
let lf = lines.len();
let lh = lines.values().filter(|&&h| h > 0).count();
writeln!(out, "LF:{lf}")?;
writeln!(out, "LH:{lh}")?;
writeln!(out, "end_of_record")?;
summary.total_lines += lf;
summary.hit_lines += lh;
}
Ok(summary)
}
fn resolve_source_path(file: &Path, workspace_root: Option<&Path>) -> Option<PathBuf> {
if file.is_absolute() {
return file.exists().then(|| file.to_path_buf());
}
let root = workspace_root?;
let candidate = root.join(file);
candidate.exists().then_some(candidate)
}
fn collect_pcs_from_traces(trace_dir: &Path) -> Result<BTreeMap<String, BTreeSet<u64>>> {
let mut result: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
if !trace_dir.exists() {
return Ok(result);
}
visit_dir(trace_dir, &mut result)?;
Ok(result)
}
fn visit_dir(dir: &Path, result: &mut BTreeMap<String, BTreeSet<u64>>) -> Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
visit_dir(&path, result)?;
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("regs") {
continue;
}
let pid_path = path.with_extension("program_id");
let program_id = match fs::read_to_string(&pid_path) {
Ok(s) => s.trim().to_string(),
Err(_) => continue,
};
let data = fs::read(&path)?;
if data.len() % REGS_ENTRY_SIZE != 0 {
eprintln!(
"warning: {} has unexpected size (not multiple of {})",
path.display(),
REGS_ENTRY_SIZE
);
continue;
}
let pcs = result.entry(program_id).or_default();
let num_steps = data.len() / REGS_ENTRY_SIZE;
for i in 0..num_steps {
let offset = i * REGS_ENTRY_SIZE + 11 * 8;
let pc = u64::from_le_bytes(data[offset..offset + 8].try_into().unwrap());
pcs.insert(pc);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use {super::*, tempfile::tempdir};
#[test]
fn generate_lcov_errors_when_no_trace_data_found() {
let dir = tempdir().unwrap();
let output = dir.path().join("lcov.info");
let err =
generate_lcov(dir.path(), &BTreeMap::new(), None, &output).expect_err("expected error");
assert!(err.to_string().contains("no trace data found"));
assert!(!output.exists());
}
#[test]
fn lcov_writer_preserves_zero_hit_lines() {
let dir = tempdir().unwrap();
let source = dir.path().join("program.rs");
let output = dir.path().join("lcov.info");
let line_hits =
BTreeMap::from([(source.clone(), BTreeMap::from([(10, 3), (11, 0), (12, 1)]))]);
let summary = write_lcov_records(&line_hits, &output).unwrap();
let lcov = fs::read_to_string(output).unwrap();
assert_eq!(
summary,
LcovSummary {
files: 1,
total_lines: 3,
hit_lines: 2,
}
);
assert!(lcov.contains(&format!("SF:{}", source.display())));
assert!(lcov.contains("DA:10,3\n"));
assert!(lcov.contains("DA:11,0\n"));
assert!(lcov.contains("DA:12,1\n"));
assert!(lcov.contains("LF:3\n"));
assert!(lcov.contains("LH:2\n"));
}
}