Repository: taiki-e/cargo-llvm-cov (rust)

Issue:
Support region coverage on codecov
readme says:

> Currently, only line coverage is available on Codecov. This is because `-Z instrument-coverage` does not support branch coverage and Codecov does not support region coverage.

However, we can probably support region coverage on codecov by converting llvm-cov's json coverage format to [codecov's json coverage format](https://docs.codecov.io/docs/codecov-custom-coverage-format).

related:
- https://llvm.org/docs/CoverageMappingFormat.html

Pull request description:
add codecov json format (support region testing)
Fixes #20 

Patch:
diff --git a/.deny.toml b/.deny.toml
index 1daaddb..f60a894 100644
--- a/.deny.toml
+++ b/.deny.toml
@@ -13,6 +13,9 @@ ignore = []
 multiple-versions = "deny"
 wildcards = "allow" # https://github.com/EmbarkStudios/cargo-deny/issues/448
 skip = []
+skip-tree = [
+    { name = "windows-sys", version = "=0.45" },
+]
 
 # https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html
 [licenses]
diff --git a/src/cli.rs b/src/cli.rs
index 8af4d63..d6235d6 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -207,6 +207,7 @@ impl Args {
         let mut json = false;
         let mut lcov = false;
         let mut cobertura = false;
+        let mut codecov = false;
         let mut text = false;
         let mut html = false;
         let mut open = false;
@@ -364,6 +365,7 @@ impl Args {
                 Long("json") => parse_flag!(json),
                 Long("lcov") => parse_flag!(lcov),
                 Long("cobertura") => parse_flag!(cobertura),
+                Long("codecov") => parse_flag!(codecov),
                 Long("text") => parse_flag!(text),
                 Long("html") => parse_flag!(html),
                 Long("open") => parse_flag!(open),
@@ -651,6 +653,21 @@ impl Args {
             if lcov {
                 conflicts(flag, "--lcov")?;
             }
+            if codecov {
+                conflicts(flag, "--codecov")?;
+            }
+        }
+        if codecov {
+            let flag = "--codecov";
+            if json {
+                conflicts(flag, "--json")?;
+            }
+            if lcov {
+                conflicts(flag, "--lcov")?;
+            }
+            if cobertura {
+                conflicts(flag, "--cobertura")?;
+            }
         }
         if text {
             let flag = "--text";
@@ -663,6 +680,9 @@ impl Args {
             if cobertura {
                 conflicts(flag, "--cobertura")?;
             }
+            if codecov {
+                conflicts(flag, "--codecov")?;
+            }
         }
         if html || open {
             let flag = if html { "--html" } else { "--open" };
@@ -675,6 +695,9 @@ impl Args {
             if cobertura {
                 conflicts(flag, "--cobertura")?;
             }
+            if codecov {
+                conflicts(flag, "--codecov")?;
+            }
             if text {
                 conflicts(flag, "--text")?;
             }
@@ -699,6 +722,9 @@ impl Args {
             if cobertura {
                 conflicts(flag, "--cobertura")?;
             }
+            if codecov {
+                conflicts(flag, "--codecov")?;
+            }
             if output_path.is_some() {
                 conflicts(flag, "--output-path")?;
             }
@@ -746,6 +772,7 @@ impl Args {
                 json,
                 lcov,
                 cobertura,
+                codecov,
                 text,
                 html,
                 open,
@@ -909,6 +936,14 @@ pub(crate) struct LlvmCovOptions {
     /// See <https://llvm.org/docs/CommandGuide/llvm-cov.html#llvm-cov-export> for more.
     pub(crate) cobertura: bool,
 
+    /// Export coverage data in "Codecov Custom Coverage" format
+    ///
+    /// If --output-path is not specified, the report will be printed to stdout.
+    ///
+    /// This internally calls `llvm-cov export -format=lcov` and then converts to codecov.json.
+    /// See <https://llvm.org/docs/CommandGuide/llvm-cov.html#llvm-cov-export> for more.
+    pub(crate) codecov: bool,
+
     /// Generate coverage report in “text” format
     ///
     /// If --output-path or --output-dir is not specified, the report will be printed to stdout.
@@ -930,12 +965,13 @@ pub(crate) struct LlvmCovOptions {
 
     /// Export only summary information for each file in the coverage data
     ///
-    /// This flag can only be used together with --json, --lcov, or --cobertura.
+    /// This flag can only be used together with --json, --lcov, --cobertura, or --codecov.
     // If the format flag is not specified, this flag is no-op because the only summary is displayed anyway.
     pub(crate) summary_only: bool,
+
     /// Specify a file to write coverage data into.
     ///
-    /// This flag can only be used together with --json, --lcov, --cobertura, or --text.
+    /// This flag can only be used together with --json, --lcov, --cobertura, --codecov, or --text.
     /// See --output-dir for --html and --open.
     pub(crate) output_path: Option<Utf8PathBuf>,
     /// Specify a directory to write coverage report into (default to `target/llvm-cov`).
diff --git a/src/json.rs b/src/json.rs
index 5e1a80c..0c74be7 100644
--- a/src/json.rs
+++ b/src/json.rs
@@ -1,7 +1,10 @@
-use std::collections::BTreeMap;
+use std::{
+    collections::BTreeMap,
+    fmt::{Debug, Formatter},
+};
 
 use anyhow::{Context as _, Result};
-use serde::{Deserialize, Serialize};
+use serde::{ser::SerializeMap, Deserialize, Serialize, Serializer};
 
 // https://github.com/llvm/llvm-project/blob/llvmorg-16.0.0/llvm/tools/llvm-cov/CoverageExporterJson.cpp#L13-L47
 #[derive(Debug, Serialize, Deserialize)]
@@ -15,6 +18,101 @@ pub struct LlvmCovJsonExport {
     pub(crate) version: String,
 }
 
+/// <https://docs.codecov.com/docs/codecov-custom-coverage-format>
+///
+/// This represents the fraction: `{covered}/{count}`.
+#[derive(Default, Debug)]
+pub(crate) struct CodeCovCoverage {
+    pub(crate) count: u64,
+    pub(crate) covered: u64,
+}
+
+impl Serialize for CodeCovCoverage {
+    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        serializer.serialize_str(&format!("{}/{}", self.covered, self.count))
+    }
+}
+
+/// line -> coverage in fraction
+#[derive(Default)]
+pub struct CodeCovExport(BTreeMap<u64, CodeCovCoverage>);
+
+/// Custom serialize [`CodeCovExport`] as "string" -> JSON (as function)
+/// Serialize as "string" -> JSON
+impl Serialize for CodeCovExport {
+    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        let mut map = serializer.serialize_map(Some(self.0.len()))?;
+        for (key, value) in &self.0 {
+            map.serialize_entry(&key.to_string(), value)?;
+        }
+        map.end()
+    }
+}
+
+#[derive(Default, Serialize)]
+pub struct CodeCovJsonExport {
+    /// filename -> list of uncovered lines.
+    pub(crate) coverage: BTreeMap<String, CodeCovExport>,
+}
+
+impl From<Export> for CodeCovJsonExport {
+    fn from(value: Export) -> Self {
+        let functions = value.functions.unwrap_or_default();
+
+        let mut coverage = BTreeMap::new();
+
+        for func in functions {
+            for filename in func.filenames {
+                for region in &func.regions {
+                    let line_start = region.line_start();
+                    let line_end = region.line_end();
+
+                    let coverage: &mut CodeCovExport =
+                        coverage.entry(filename.clone()).or_default();
+
+                    for line in line_start..=line_end {
+                        let coverage = coverage.0.entry(line).or_default();
+                        coverage.count += 1;
+                        coverage.covered += u64::from(region.execution_count() > 0);
+                    }
+                }
+            }
+        }
+
+        Self { coverage }
+    }
+}
+
+impl From<LlvmCovJsonExport> for CodeCovJsonExport {
+    fn from(value: LlvmCovJsonExport) -> Self {
+        let exports: Vec<_> = value.data.into_iter().map(CodeCovJsonExport::from).collect();
+
+        let mut combined = CodeCovJsonExport::default();
+
+        for export in exports {
+            for (filename, coverage) in export.coverage {
+                let combined = combined.coverage.entry(filename).or_default();
+                for (line, coverage) in coverage.0 {
+                    let combined = combined
+                        .0
+                        .entry(line)
+                        .or_insert_with(|| CodeCovCoverage { count: 0, covered: 0 });
+                    combined.count += coverage.count;
+                    combined.covered += coverage.covered;
+                }
+            }
+        }
+
+        combined
+    }
+}
+
 /// Files -> list of uncovered lines.
 pub(crate) type UncoveredLines = BTreeMap<String, Vec<u64>>;
 
@@ -52,10 +150,7 @@ impl LlvmCovJsonExport {
     pub fn get_uncovered_lines(&self, ignore_filename_regex: &Option<String>) -> UncoveredLines {
         let mut uncovered_files: UncoveredLines = BTreeMap::new();
         let mut covered_files: UncoveredLines = BTreeMap::new();
-        let mut re: Option<regex::Regex> = None;
-        if let Some(ref ignore_filename_regex) = *ignore_filename_regex {
-            re = Some(regex::Regex::new(ignore_filename_regex).unwrap());
-        }
+        let re = ignore_filename_regex.as_ref().map(|s| regex::Regex::new(s).unwrap());
         for data in &self.data {
             if let Some(ref functions) = data.functions {
                 // Iterate over all functions inside the coverage data.
@@ -205,7 +300,7 @@ pub(crate) struct File {
 
 /// Describes a segment of the file with a counter
 // https://github.com/llvm/llvm-project/blob/llvmorg-16.0.0/llvm/tools/llvm-cov/CoverageExporterJson.cpp#L79
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Serialize, Deserialize)]
 #[cfg_attr(test, serde(deny_unknown_fields))]
 pub(crate) struct Segment(
     /* Line */ pub(crate) u64,
@@ -216,6 +311,45 @@ pub(crate) struct Segment(
     /* IsGapRegion */ pub(crate) bool,
 );
 
+impl Segment {
+    pub(crate) fn line(&self) -> u64 {
+        self.0
+    }
+
+    pub(crate) fn col(&self) -> u64 {
+        self.1
+    }
+
+    pub(crate) fn count(&self) -> u64 {
+        self.2
+    }
+
+    pub(crate) fn has_count(&self) -> bool {
+        self.3
+    }
+
+    pub(crate) fn is_region_entry(&self) -> bool {
+        self.4
+    }
+
+    pub(crate) fn is_gap_region(&self) -> bool {
+        self.5
+    }
+}
+
+impl Debug for Segment {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("Segment")
+            .field("line", &self.line())
+            .field("col", &self.col())
+            .field("count", &self.count())
+            .field("has_count", &self.has_count())
+            .field("is_region_entry", &self.is_region_entry())
+            .field("is_gap_region", &self.is_gap_region())
+            .finish()
+    }
+}
+
 // https://github.com/llvm/llvm-project/blob/llvmorg-16.0.0/llvm/tools/llvm-cov/CoverageExporterJson.cpp#L258
 /// Coverage info for a single function
 #[derive(Debug, Serialize, Deserialize)]
@@ -229,7 +363,7 @@ pub(crate) struct Function {
     pub(crate) regions: Vec<Region>,
 }
 
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Copy, Clone, Serialize, Deserialize)]
 #[cfg_attr(test, serde(deny_unknown_fields))]
 pub(crate) struct Region(
     /* LineStart */ pub(crate) u64,
@@ -242,6 +376,55 @@ pub(crate) struct Region(
     /* Kind */ pub(crate) u64,
 );
 
+impl Region {
+    pub(crate) fn line_start(&self) -> u64 {
+        self.0
+    }
+
+    pub(crate) fn column_start(&self) -> u64 {
+        self.1
+    }
+
+    pub(crate) fn line_end(&self) -> u64 {
+        self.2
+    }
+
+    pub(crate) fn column_end(&self) -> u64 {
+        self.3
+    }
+
+    pub(crate) fn execution_count(&self) -> u64 {
+        self.4
+    }
+
+    pub(crate) fn file_id(&self) -> u64 {
+        self.5
+    }
+
+    pub(crate) fn expanded_file_id(&self) -> u64 {
+        self.6
+    }
+
+    pub(crate) fn kind(&self) -> u64 {
+        self.7
+    }
+}
+
+impl Debug for Region {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("Region")
+            .field("line_start", &self.line_start())
+            .field("column_start", &self.column_start())
+            .field("line_end", &self.line_end())
+            .field("column_end", &self.column_end())
+            .field("execution_count", &self.execution_count())
+            .field("file_id", &self.file_id())
+            .field("expanded_file_id", &self.expanded_file_id())
+            .field("kind", &self.kind())
+            .finish()
+    }
+}
+
 /// Object summarizing the coverage for this file
 #[derive(Debug, Serialize, Deserialize)]
 #[cfg_attr(test, serde(deny_unknown_fields))]
@@ -277,13 +460,14 @@ mod tests {
     use super::*;
 
     #[test]
-    fn parse_llvm_cov_json() {
+    fn test_parse_llvm_cov_json() {
         let files: Vec<_> = glob::glob(&format!(
             "{}/tests/fixtures/coverage-reports/**/*.json",
             env!("CARGO_MANIFEST_DIR")
         ))
         .unwrap()
         .filter_map(Result::ok)
+        .filter(|path| !path.to_str().unwrap().contains("codecov.json"))
         .collect();
         assert!(!files.is_empty());
 
diff --git a/src/main.rs b/src/main.rs
index bb4aaef..0c70820 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -15,21 +15,6 @@
 // - https://llvm.org/docs/CommandGuide/llvm-profdata.html
 // - https://llvm.org/docs/CommandGuide/llvm-cov.html
 
-#[macro_use]
-mod term;
-
-#[macro_use]
-mod process;
-
-mod cargo;
-mod clean;
-mod cli;
-mod context;
-mod demangle;
-mod env;
-mod fs;
-mod regex_vec;
-
 use std::{
     collections::{BTreeSet, HashMap},
     ffi::{OsStr, OsString},
@@ -41,7 +26,7 @@ use std::{
 use anyhow::{bail, Context as _, Result};
 use camino::{Utf8Path, Utf8PathBuf};
 use cargo_config2::Flags;
-use cargo_llvm_cov::json;
+use cargo_llvm_cov::json::{CodeCovJsonExport, LlvmCovJsonExport};
 use regex::Regex;
 use walkdir::WalkDir;
 
@@ -49,12 +34,26 @@ use crate::{
     cargo::Workspace,
     cli::{Args, ShowEnvOptions, Subcommand},
     context::Context,
-    json::LlvmCovJsonExport,
     process::ProcessBuilder,
     regex_vec::{RegexVec, RegexVecBuilder},
     term::Coloring,
 };
 
+#[macro_use]
+mod term;
+
+#[macro_use]
+mod process;
+
+mod cargo;
+mod clean;
+mod cli;
+mod context;
+mod demangle;
+mod env;
+mod fs;
+mod regex_vec;
+
 fn main() {
     term::init_coloring();
     if let Err(e) = try_main() {
@@ -748,6 +747,8 @@ enum Format {
     LCov,
     /// `llvm-cov export -format=lcov` later converted to XML
     Cobertura,
+    /// `llvm-cov show -format=lcov` later converted to Codecov JSON
+    Codecov,
     /// `llvm-cov show -format=text`
     Text,
     /// `llvm-cov show -format=html`
@@ -762,6 +763,8 @@ impl Format {
             Self::LCov
         } else if cx.args.cov.cobertura {
             Self::Cobertura
+        } else if cx.args.cov.codecov {
+            Self::Codecov
         } else if cx.args.cov.text {
             Self::Text
         } else if cx.args.cov.html {
@@ -774,7 +777,7 @@ impl Format {
     const fn llvm_cov_args(self) -> &'static [&'static str] {
         match self {
             Self::None => &["report"],
-            Self::Json => &["export", "-format=text"],
+            Self::Json | Self::Codecov => &["export", "-format=text"],
             Self::LCov | Self::Cobertura => &["export", "-format=lcov"],
             Self::Text => &["show", "-format=text"],
             Self::Html => &["show", "-format=html"],
@@ -833,7 +836,7 @@ impl Format {
                     }
                 }
             }
-            Self::Json | Self::LCov | Self::Cobertura => {
+            Self::Json | Self::LCov | Self::Cobertura | Self::Codecov => {
                 if cx.args.cov.summary_only {
                     cmd.arg("-summary-only");
                 }
@@ -868,7 +871,27 @@ impl Format {
                 println!("{out}");
             }
             return Ok(());
-        }
+        };
+
+        if cx.args.cov.codecov {
+            if term::verbose() {
+                status!("Running", "{cmd}");
+            }
+            let cov = cmd.read()?;
+            let cov: LlvmCovJsonExport = serde_json::from_str(&cov)?;
+            let cov = CodeCovJsonExport::from(cov);
+            let out = serde_json::to_string(&cov)?;
+
+            if let Some(output_path) = &cx.args.cov.output_path {
+                fs::write(output_path, out)?;
+                eprintln!();
+                status!("Finished", "report saved to {output_path}");
+            } else {
+                // write JSON to stdout
+                println!("{out}");
+            }
+            return Ok(());
+        };
 
         if let Some(output_path) = &cx.args.cov.output_path {
             if term::verbose() {


Test patch:
diff --git a/tests/fixtures/coverage-reports/bin_crate/bin_crate.codecov.json b/tests/fixtures/coverage-reports/bin_crate/bin_crate.codecov.json
new file mode 100644
index 0000000..305f6c8
--- /dev/null
+++ b/tests/fixtures/coverage-reports/bin_crate/bin_crate.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"src/main.rs":{"1":"2/3","2":"1/2","3":"1/2","4":"1/2","5":"0/2","6":"0/2","8":"1/2"},"tests/test.rs":{"1":"1/1","3":"1/1","4":"1/1","5":"1/1","10":"1/1","11":"1/1","12":"1/1","13":"1/1","14":"1/1","15":"1/1","19":"1/1","20":"1/1","21":"1/1","22":"1/1","23":"1/1","24":"1/1","25":"1/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/bin_crate/run.codecov.json b/tests/fixtures/coverage-reports/bin_crate/run.codecov.json
new file mode 100644
index 0000000..74312c0
--- /dev/null
+++ b/tests/fixtures/coverage-reports/bin_crate/run.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"src/main.rs":{"1":"1/1","2":"1/1","3":"0/1","4":"1/1","5":"0/1","6":"0/1","8":"1/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/cargo_config/cargo_config.codecov.json b/tests/fixtures/coverage-reports/cargo_config/cargo_config.codecov.json
new file mode 100644
index 0000000..1da5f89
--- /dev/null
+++ b/tests/fixtures/coverage-reports/cargo_config/cargo_config.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"src/lib.rs":{"1":"2/2","2":"1/1","3":"0/1","5":"1/1","7":"1/1","9":"1/1","10":"1/1","11":"1/1","12":"1/1","15":"1/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/cargo_config_toml/cargo_config_toml.codecov.json b/tests/fixtures/coverage-reports/cargo_config_toml/cargo_config_toml.codecov.json
new file mode 100644
index 0000000..1da5f89
--- /dev/null
+++ b/tests/fixtures/coverage-reports/cargo_config_toml/cargo_config_toml.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"src/lib.rs":{"1":"2/2","2":"1/1","3":"0/1","5":"1/1","7":"1/1","9":"1/1","10":"1/1","11":"1/1","12":"1/1","15":"1/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/coverage_helper/coverage_helper.codecov.json b/tests/fixtures/coverage-reports/coverage_helper/coverage_helper.codecov.json
new file mode 100644
index 0000000..93ccb4d
--- /dev/null
+++ b/tests/fixtures/coverage-reports/coverage_helper/coverage_helper.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"src/lib.rs":{"1":"1/1","5":"1/1","6":"1/1","7":"1/1","8":"0/1","9":"1/1","10":"0/1","11":"0/1","13":"1/1","15":"1/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/instantiations/instantiations.codecov.json b/tests/fixtures/coverage-reports/instantiations/instantiations.codecov.json
new file mode 100644
index 0000000..9193b9b
--- /dev/null
+++ b/tests/fixtures/coverage-reports/instantiations/instantiations.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"src/lib.rs":{"1":"1/1","3":"2/2","4":"2/2","5":"1/2","7":"1/2","9":"2/2","11":"1/1","12":"1/1","13":"1/1","14":"1/1","15":"1/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/merge/clean_ws.codecov.json b/tests/fixtures/coverage-reports/merge/clean_ws.codecov.json
new file mode 100644
index 0000000..1da5f89
--- /dev/null
+++ b/tests/fixtures/coverage-reports/merge/clean_ws.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"src/lib.rs":{"1":"2/2","2":"1/1","3":"0/1","5":"1/1","7":"1/1","9":"1/1","10":"1/1","11":"1/1","12":"1/1","15":"1/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/merge/merge.codecov.json b/tests/fixtures/coverage-reports/merge/merge.codecov.json
new file mode 100644
index 0000000..20c4d82
--- /dev/null
+++ b/tests/fixtures/coverage-reports/merge/merge.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"src/lib.rs":{"1":"4/4","2":"2/2","3":"1/2","5":"1/2","7":"2/2","9":"2/2","10":"2/2","11":"2/2","12":"2/2","13":"1/1","14":"1/1","15":"2/2"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/no_coverage/no_cfg_coverage.codecov.json b/tests/fixtures/coverage-reports/no_coverage/no_cfg_coverage.codecov.json
new file mode 100644
index 0000000..b0cdc6b
--- /dev/null
+++ b/tests/fixtures/coverage-reports/no_coverage/no_cfg_coverage.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"src/lib.rs":{"1":"1/1","3":"1/1","4":"1/1","5":"1/1","6":"0/1","7":"0/1","8":"0/1","9":"0/1","11":"1/1","14":"1/1","15":"1/1","16":"1/1","17":"1/1","18":"1/2","19":"0/1","20":"1/2","21":"1/1","24":"1/1","25":"1/1","26":"1/2","27":"0/1","28":"0/1","29":"1/2","30":"1/1","37":"1/1","38":"1/1","39":"1/2","40":"0/1","41":"1/2","42":"1/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/no_coverage/no_coverage.codecov.json b/tests/fixtures/coverage-reports/no_coverage/no_coverage.codecov.json
new file mode 100644
index 0000000..62e518f
--- /dev/null
+++ b/tests/fixtures/coverage-reports/no_coverage/no_coverage.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"src/lib.rs":{"1":"1/1","3":"1/1","4":"1/1","5":"1/1","6":"0/1","7":"0/1","8":"0/1","9":"0/1","11":"1/1","14":"1/1","24":"1/1","25":"1/1","26":"1/2","27":"0/1","28":"0/1","29":"1/2","30":"1/1","37":"1/1","38":"1/1","39":"1/2","40":"0/1","41":"1/2","42":"1/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/no_test/link_dead_code.codecov.json b/tests/fixtures/coverage-reports/no_test/link_dead_code.codecov.json
new file mode 100644
index 0000000..d7ed893
--- /dev/null
+++ b/tests/fixtures/coverage-reports/no_test/link_dead_code.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"src/lib.rs":{"1":"1/1","4":"1/1","5":"1/1"},"src/module.rs":{"1":"0/1","2":"0/1","3":"0/1","4":"0/1","5":"0/1","6":"0/1","8":"0/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/no_test/no_test.codecov.json b/tests/fixtures/coverage-reports/no_test/no_test.codecov.json
new file mode 100644
index 0000000..d7ed893
--- /dev/null
+++ b/tests/fixtures/coverage-reports/no_test/no_test.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"src/lib.rs":{"1":"1/1","4":"1/1","5":"1/1"},"src/module.rs":{"1":"0/1","2":"0/1","3":"0/1","4":"0/1","5":"0/1","6":"0/1","8":"0/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/real1/all.codecov.json b/tests/fixtures/coverage-reports/real1/all.codecov.json
new file mode 100644
index 0000000..d02d7f2
--- /dev/null
+++ b/tests/fixtures/coverage-reports/real1/all.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"member1/member2/src/lib.rs":{"1":"2/3","2":"1/2","3":"1/2","4":"0/2","5":"0/2","6":"0/2","8":"1/2"},"member1/src/lib.rs":{"1":"2/3","2":"1/2","3":"1/2","4":"0/2","5":"0/2","6":"0/2","8":"1/2"},"src/lib.rs":{"1":"1/1","4":"1/1","5":"1/1","6":"0/1","7":"1/1","8":"0/1","9":"1/1","11":"1/1","13":"1/1","14":"1/1","15":"1/1","16":"1/1","17":"1/1","18":"1/1","19":"1/1"},"src/tests/mod.rs":{"9":"1/1","10":"1/1","11":"1/1","12":"1/1","13":"1/1","14":"1/1","15":"1/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/real1/exclude.codecov.json b/tests/fixtures/coverage-reports/real1/exclude.codecov.json
new file mode 100644
index 0000000..14fb141
--- /dev/null
+++ b/tests/fixtures/coverage-reports/real1/exclude.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"member1/member2/src/lib.rs":{"1":"1/2","2":"0/1","3":"0/1","4":"0/1","5":"0/1","6":"0/1","8":"0/1"},"member1/src/lib.rs":{"1":"1/2","2":"0/1","3":"0/1","4":"0/1","5":"0/1","6":"0/1","8":"0/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/real1/manifest_path.codecov.json b/tests/fixtures/coverage-reports/real1/manifest_path.codecov.json
new file mode 100644
index 0000000..57c5352
--- /dev/null
+++ b/tests/fixtures/coverage-reports/real1/manifest_path.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"member1/member2/src/lib.rs":{"1":"1/2","2":"0/1","3":"0/1","4":"0/1","5":"0/1","6":"0/1","8":"0/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/real1/package1.codecov.json b/tests/fixtures/coverage-reports/real1/package1.codecov.json
new file mode 100644
index 0000000..57c5352
--- /dev/null
+++ b/tests/fixtures/coverage-reports/real1/package1.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"member1/member2/src/lib.rs":{"1":"1/2","2":"0/1","3":"0/1","4":"0/1","5":"0/1","6":"0/1","8":"0/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/real1/workspace_root.codecov.json b/tests/fixtures/coverage-reports/real1/workspace_root.codecov.json
new file mode 100644
index 0000000..fc65b86
--- /dev/null
+++ b/tests/fixtures/coverage-reports/real1/workspace_root.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"member1/member2/src/lib.rs":{"1":"1/1","2":"1/1","3":"1/1","4":"0/1","5":"0/1","6":"0/1","8":"1/1"},"member1/src/lib.rs":{"1":"1/1","2":"1/1","3":"1/1","4":"0/1","5":"0/1","6":"0/1","8":"1/1"},"src/lib.rs":{"1":"1/1","4":"1/1","5":"1/1","6":"0/1","7":"1/1","8":"0/1","9":"1/1","11":"1/1","13":"1/1","14":"1/1","15":"1/1","16":"1/1","17":"1/1","18":"1/1","19":"1/1"},"src/tests/mod.rs":{"9":"1/1","10":"1/1","11":"1/1","12":"1/1","13":"1/1","14":"1/1","15":"1/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/virtual1/exclude-from-report1.codecov.json b/tests/fixtures/coverage-reports/virtual1/exclude-from-report1.codecov.json
new file mode 100644
index 0000000..42a0101
--- /dev/null
+++ b/tests/fixtures/coverage-reports/virtual1/exclude-from-report1.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"member1/src/lib.rs":{"1":"2/2","2":"1/1","3":"0/1","4":"1/1","5":"0/1","6":"1/1","8":"1/1","10":"1/1","11":"1/1","12":"1/1","13":"1/1","14":"1/1","15":"1/1","16":"1/1","17":"1/1"},"member2/member3/src/lib.rs":{"1":"2/3","2":"1/2","3":"1/2","4":"0/2","5":"0/2","6":"0/2","8":"1/2"},"member2/src/lib.rs":{"1":"2/3","2":"1/2","3":"1/2","4":"0/2","5":"0/2","6":"0/2","8":"1/2","10":"1/2","11":"1/2","12":"1/2","13":"0/2","14":"1/2","15":"0/2","17":"1/2","19":"1/1","20":"1/1","21":"1/1","22":"1/1","23":"1/1"},"member2/src/member4/src/lib.rs":{"1":"2/3","2":"1/2","3":"1/2","4":"0/2","5":"0/2","6":"0/2","8":"1/2"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/virtual1/exclude-from-report2.codecov.json b/tests/fixtures/coverage-reports/virtual1/exclude-from-report2.codecov.json
new file mode 100644
index 0000000..42a0101
--- /dev/null
+++ b/tests/fixtures/coverage-reports/virtual1/exclude-from-report2.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"member1/src/lib.rs":{"1":"2/2","2":"1/1","3":"0/1","4":"1/1","5":"0/1","6":"1/1","8":"1/1","10":"1/1","11":"1/1","12":"1/1","13":"1/1","14":"1/1","15":"1/1","16":"1/1","17":"1/1"},"member2/member3/src/lib.rs":{"1":"2/3","2":"1/2","3":"1/2","4":"0/2","5":"0/2","6":"0/2","8":"1/2"},"member2/src/lib.rs":{"1":"2/3","2":"1/2","3":"1/2","4":"0/2","5":"0/2","6":"0/2","8":"1/2","10":"1/2","11":"1/2","12":"1/2","13":"0/2","14":"1/2","15":"0/2","17":"1/2","19":"1/1","20":"1/1","21":"1/1","22":"1/1","23":"1/1"},"member2/src/member4/src/lib.rs":{"1":"2/3","2":"1/2","3":"1/2","4":"0/2","5":"0/2","6":"0/2","8":"1/2"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/virtual1/exclude-from-test1.codecov.json b/tests/fixtures/coverage-reports/virtual1/exclude-from-test1.codecov.json
new file mode 100644
index 0000000..fb0b02f
--- /dev/null
+++ b/tests/fixtures/coverage-reports/virtual1/exclude-from-test1.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"member2/member3/src/lib.rs":{"1":"1/2","2":"0/1","3":"0/1","4":"0/1","5":"0/1","6":"0/1","8":"0/1"},"member2/src/lib.rs":{"1":"1/2","2":"0/1","3":"0/1","4":"0/1","5":"0/1","6":"0/1","8":"0/1","10":"1/1","11":"1/1","12":"1/1","13":"0/1","14":"1/1","15":"0/1","17":"1/1","19":"1/1","20":"1/1","21":"1/1","22":"1/1","23":"1/1"},"member2/src/member4/src/lib.rs":{"1":"1/2","2":"0/1","3":"0/1","4":"0/1","5":"0/1","6":"0/1","8":"0/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/virtual1/exclude-from-test2.codecov.json b/tests/fixtures/coverage-reports/virtual1/exclude-from-test2.codecov.json
new file mode 100644
index 0000000..2859a28
--- /dev/null
+++ b/tests/fixtures/coverage-reports/virtual1/exclude-from-test2.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"member1/src/lib.rs":{"1":"2/2","2":"1/1","3":"0/1","4":"1/1","5":"0/1","6":"1/1","8":"1/1","10":"1/1","11":"1/1","12":"1/1","13":"1/1","14":"1/1","15":"1/1","16":"1/1","17":"1/1"},"member2/member3/src/lib.rs":{"1":"2/3","2":"1/2","3":"1/2","4":"0/2","5":"0/2","6":"0/2","8":"1/2"},"member2/src/lib.rs":{"1":"1/1","2":"1/1","3":"1/1","4":"0/1","5":"0/1","6":"0/1","8":"1/1","10":"0/1","11":"0/1","12":"0/1","13":"0/1","14":"0/1","15":"0/1","17":"0/1"},"member2/src/member4/src/lib.rs":{"1":"2/3","2":"1/2","3":"1/2","4":"0/2","5":"0/2","6":"0/2","8":"1/2"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/virtual1/exclude1.codecov.json b/tests/fixtures/coverage-reports/virtual1/exclude1.codecov.json
new file mode 100644
index 0000000..fb0b02f
--- /dev/null
+++ b/tests/fixtures/coverage-reports/virtual1/exclude1.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"member2/member3/src/lib.rs":{"1":"1/2","2":"0/1","3":"0/1","4":"0/1","5":"0/1","6":"0/1","8":"0/1"},"member2/src/lib.rs":{"1":"1/2","2":"0/1","3":"0/1","4":"0/1","5":"0/1","6":"0/1","8":"0/1","10":"1/1","11":"1/1","12":"1/1","13":"0/1","14":"1/1","15":"0/1","17":"1/1","19":"1/1","20":"1/1","21":"1/1","22":"1/1","23":"1/1"},"member2/src/member4/src/lib.rs":{"1":"1/2","2":"0/1","3":"0/1","4":"0/1","5":"0/1","6":"0/1","8":"0/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/virtual1/exclude2.codecov.json b/tests/fixtures/coverage-reports/virtual1/exclude2.codecov.json
new file mode 100644
index 0000000..2859a28
--- /dev/null
+++ b/tests/fixtures/coverage-reports/virtual1/exclude2.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"member1/src/lib.rs":{"1":"2/2","2":"1/1","3":"0/1","4":"1/1","5":"0/1","6":"1/1","8":"1/1","10":"1/1","11":"1/1","12":"1/1","13":"1/1","14":"1/1","15":"1/1","16":"1/1","17":"1/1"},"member2/member3/src/lib.rs":{"1":"2/3","2":"1/2","3":"1/2","4":"0/2","5":"0/2","6":"0/2","8":"1/2"},"member2/src/lib.rs":{"1":"1/1","2":"1/1","3":"1/1","4":"0/1","5":"0/1","6":"0/1","8":"1/1","10":"0/1","11":"0/1","12":"0/1","13":"0/1","14":"0/1","15":"0/1","17":"0/1"},"member2/src/member4/src/lib.rs":{"1":"2/3","2":"1/2","3":"1/2","4":"0/2","5":"0/2","6":"0/2","8":"1/2"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/virtual1/package1.codecov.json b/tests/fixtures/coverage-reports/virtual1/package1.codecov.json
new file mode 100644
index 0000000..a17886a
--- /dev/null
+++ b/tests/fixtures/coverage-reports/virtual1/package1.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"member1/src/lib.rs":{"1":"2/2","2":"1/1","3":"0/1","4":"1/1","5":"0/1","6":"1/1","8":"1/1","10":"1/1","11":"1/1","12":"1/1","13":"1/1","14":"1/1","15":"1/1","16":"1/1","17":"1/1"},"member2/member3/src/lib.rs":{"1":"1/1","2":"1/1","3":"1/1","4":"0/1","5":"0/1","6":"0/1","8":"1/1"},"member2/src/lib.rs":{"1":"1/1","2":"1/1","3":"1/1","4":"0/1","5":"0/1","6":"0/1","8":"1/1","10":"0/1","11":"0/1","12":"0/1","13":"0/1","14":"0/1","15":"0/1","17":"0/1"},"member2/src/member4/src/lib.rs":{"1":"1/1","2":"1/1","3":"1/1","4":"0/1","5":"0/1","6":"0/1","8":"1/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/virtual1/package2.codecov.json b/tests/fixtures/coverage-reports/virtual1/package2.codecov.json
new file mode 100644
index 0000000..2ff2bc5
--- /dev/null
+++ b/tests/fixtures/coverage-reports/virtual1/package2.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"member1/src/lib.rs":{"1":"2/2","2":"1/1","3":"0/1","4":"1/1","5":"0/1","6":"1/1","8":"1/1","10":"1/1","11":"1/1","12":"1/1","13":"1/1","14":"1/1","15":"1/1","16":"1/1","17":"1/1"},"member2/member3/src/lib.rs":{"1":"1/1","2":"1/1","3":"1/1","4":"0/1","5":"0/1","6":"0/1","8":"1/1"},"member2/src/lib.rs":{"1":"2/3","2":"1/2","3":"1/2","4":"0/2","5":"0/2","6":"0/2","8":"1/2","10":"1/2","11":"1/2","12":"1/2","13":"0/2","14":"1/2","15":"0/2","17":"1/2","19":"1/1","20":"1/1","21":"1/1","22":"1/1","23":"1/1"},"member2/src/member4/src/lib.rs":{"1":"1/1","2":"1/1","3":"1/1","4":"0/1","5":"0/1","6":"0/1","8":"1/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/virtual1/package3.codecov.json b/tests/fixtures/coverage-reports/virtual1/package3.codecov.json
new file mode 100644
index 0000000..4bc22a5
--- /dev/null
+++ b/tests/fixtures/coverage-reports/virtual1/package3.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"member2/src/lib.rs":{"1":"1/2","2":"0/1","3":"0/1","4":"0/1","5":"0/1","6":"0/1","8":"0/1","10":"1/1","11":"1/1","12":"1/1","13":"0/1","14":"1/1","15":"0/1","17":"1/1","19":"1/1","20":"1/1","21":"1/1","22":"1/1","23":"1/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/virtual1/package4.codecov.json b/tests/fixtures/coverage-reports/virtual1/package4.codecov.json
new file mode 100644
index 0000000..d94835d
--- /dev/null
+++ b/tests/fixtures/coverage-reports/virtual1/package4.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"member2/member3/src/lib.rs":{"1":"1/2","2":"0/1","3":"0/1","4":"0/1","5":"0/1","6":"0/1","8":"0/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/virtual1/package5.codecov.json b/tests/fixtures/coverage-reports/virtual1/package5.codecov.json
new file mode 100644
index 0000000..709b974
--- /dev/null
+++ b/tests/fixtures/coverage-reports/virtual1/package5.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"member2/src/member4/src/lib.rs":{"1":"1/2","2":"0/1","3":"0/1","4":"0/1","5":"0/1","6":"0/1","8":"0/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/virtual1/package6.codecov.json b/tests/fixtures/coverage-reports/virtual1/package6.codecov.json
new file mode 100644
index 0000000..20fd364
--- /dev/null
+++ b/tests/fixtures/coverage-reports/virtual1/package6.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"member2/member3/src/lib.rs":{"1":"1/2","2":"0/1","3":"0/1","4":"0/1","5":"0/1","6":"0/1","8":"0/1"},"member2/src/member4/src/lib.rs":{"1":"1/2","2":"0/1","3":"0/1","4":"0/1","5":"0/1","6":"0/1","8":"0/1"}}}
\ No newline at end of file
diff --git a/tests/fixtures/coverage-reports/virtual1/workspace_root.codecov.json b/tests/fixtures/coverage-reports/virtual1/workspace_root.codecov.json
new file mode 100644
index 0000000..42a0101
--- /dev/null
+++ b/tests/fixtures/coverage-reports/virtual1/workspace_root.codecov.json
@@ -0,0 +1,1 @@
+{"coverage":{"member1/src/lib.rs":{"1":"2/2","2":"1/1","3":"0/1","4":"1/1","5":"0/1","6":"1/1","8":"1/1","10":"1/1","11":"1/1","12":"1/1","13":"1/1","14":"1/1","15":"1/1","16":"1/1","17":"1/1"},"member2/member3/src/lib.rs":{"1":"2/3","2":"1/2","3":"1/2","4":"0/2","5":"0/2","6":"0/2","8":"1/2"},"member2/src/lib.rs":{"1":"2/3","2":"1/2","3":"1/2","4":"0/2","5":"0/2","6":"0/2","8":"1/2","10":"1/2","11":"1/2","12":"1/2","13":"0/2","14":"1/2","15":"0/2","17":"1/2","19":"1/1","20":"1/1","21":"1/1","22":"1/1","23":"1/1"},"member2/src/member4/src/lib.rs":{"1":"2/3","2":"1/2","3":"1/2","4":"0/2","5":"0/2","6":"0/2","8":"1/2"}}}
\ No newline at end of file
diff --git a/tests/test.rs b/tests/test.rs
index 1cfed3f..994d362 100644
--- a/tests/test.rs
+++ b/tests/test.rs
@@ -1,7 +1,5 @@
 #![warn(rust_2018_idioms)]
 
-mod auxiliary;
-
 use anyhow::Context as _;
 use auxiliary::{
     assert_output, cargo_llvm_cov, fixtures_path, normalize_output, perturb_one_header,
@@ -11,6 +9,8 @@ use camino::Utf8Path;
 use fs_err as fs;
 use tempfile::tempdir;
 
+mod auxiliary;
+
 const SUBCOMMANDS: &[&str] = &["", "run", "report", "clean", "show-env", "nextest"];
 
 fn test_set() -> Vec<(&'static str, &'static [&'static str])> {
@@ -23,6 +23,7 @@ fn test_set() -> Vec<(&'static str, &'static [&'static str])> {
         // ("full.json", &["--json"]),
         ("lcov.info", &["--lcov", "--summary-only"]),
         // TODO: test Cobertura output
+        ("codecov.json", &["--codecov"]),
     ]
 }