summaryrefslogtreecommitdiff
path: root/cli/tools
diff options
context:
space:
mode:
Diffstat (limited to 'cli/tools')
-rw-r--r--cli/tools/coverage/mod.rs388
-rw-r--r--cli/tools/coverage/style.css371
-rw-r--r--cli/tools/coverage/util.rs103
3 files changed, 853 insertions, 9 deletions
diff --git a/cli/tools/coverage/mod.rs b/cli/tools/coverage/mod.rs
index cd4afe360..79899ddd8 100644
--- a/cli/tools/coverage/mod.rs
+++ b/cli/tools/coverage/mod.rs
@@ -1,6 +1,7 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
use crate::args::CoverageFlags;
+use crate::args::CoverageType;
use crate::args::FileFlags;
use crate::args::Flags;
use crate::cdp;
@@ -24,6 +25,7 @@ use deno_core::url::Url;
use deno_core::LocalInspectorSession;
use deno_core::ModuleCode;
use regex::Regex;
+use std::collections::HashMap;
use std::fs;
use std::fs::File;
use std::io::BufWriter;
@@ -37,6 +39,7 @@ use uuid::Uuid;
mod merge;
mod range_tree;
+mod util;
use merge::ProcessCoverage;
pub struct CoverageCollector {
@@ -156,6 +159,7 @@ impl CoverageCollector {
}
}
+#[derive(Debug, Clone)]
struct BranchCoverageItem {
line_index: usize,
block_number: usize,
@@ -164,20 +168,36 @@ struct BranchCoverageItem {
is_hit: bool,
}
+#[derive(Debug, Clone)]
struct FunctionCoverageItem {
name: String,
line_index: usize,
execution_count: i64,
}
+#[derive(Debug, Clone)]
struct CoverageReport {
url: ModuleSpecifier,
named_functions: Vec<FunctionCoverageItem>,
branches: Vec<BranchCoverageItem>,
+ /// (line_index, number_of_hits)
found_lines: Vec<(usize, i64)>,
output: Option<PathBuf>,
}
+#[derive(Default)]
+struct CoverageStats<'a> {
+ pub line_hit: usize,
+ pub line_miss: usize,
+ pub branch_hit: usize,
+ pub branch_miss: usize,
+ pub parent: Option<String>,
+ pub file_text: Option<String>,
+ pub report: Option<&'a CoverageReport>,
+}
+
+type CoverageSummary<'a> = HashMap<String, CoverageStats<'a>>;
+
fn generate_coverage_report(
script_coverage: &cdp::ScriptCoverage,
script_source: String,
@@ -371,6 +391,7 @@ fn generate_coverage_report(
enum CoverageReporterKind {
Pretty,
Lcov,
+ Html,
}
fn create_reporter(
@@ -379,6 +400,7 @@ fn create_reporter(
match kind {
CoverageReporterKind::Lcov => Box::new(LcovCoverageReporter::new()),
CoverageReporterKind::Pretty => Box::new(PrettyCoverageReporter::new()),
+ CoverageReporterKind::Html => Box::new(HtmlCoverageReporter::new()),
}
}
@@ -389,7 +411,7 @@ trait CoverageReporter {
file_text: &str,
) -> Result<(), AnyError>;
- fn done(&mut self);
+ fn done(&mut self, _coverage_root: &Path) {}
}
struct LcovCoverageReporter {}
@@ -490,8 +512,6 @@ impl CoverageReporter for LcovCoverageReporter {
writeln!(out_writer, "end_of_record")?;
Ok(())
}
-
- fn done(&mut self) {}
}
struct PrettyCoverageReporter {}
@@ -563,8 +583,353 @@ impl CoverageReporter for PrettyCoverageReporter {
}
Ok(())
}
+}
+
+struct HtmlCoverageReporter {
+ file_reports: Vec<(CoverageReport, String)>,
+}
+
+impl HtmlCoverageReporter {
+ pub fn new() -> HtmlCoverageReporter {
+ HtmlCoverageReporter {
+ file_reports: Vec::new(),
+ }
+ }
+}
+
+impl CoverageReporter for HtmlCoverageReporter {
+ fn report(
+ &mut self,
+ report: &CoverageReport,
+ text: &str,
+ ) -> Result<(), AnyError> {
+ self.file_reports.push((report.clone(), text.to_string()));
+ Ok(())
+ }
+
+ fn done(&mut self, coverage_root: &Path) {
+ let summary = self.collect_summary();
+ let now = crate::util::time::utc_now().to_rfc2822();
+
+ for (node, stats) in &summary {
+ let report_path =
+ self.get_report_path(coverage_root, node, stats.file_text.is_none());
+ let main_content = if let Some(file_text) = &stats.file_text {
+ self.create_html_code_table(file_text, stats.report.unwrap())
+ } else {
+ self.create_html_summary_table(node, &summary)
+ };
+ let is_dir = stats.file_text.is_none();
+ let html = self.create_html(node, is_dir, stats, &now, &main_content);
+ fs::create_dir_all(report_path.parent().unwrap()).unwrap();
+ fs::write(report_path, html).unwrap();
+ }
+
+ let root_report = Url::from_file_path(
+ coverage_root
+ .join("html")
+ .join("index.html")
+ .canonicalize()
+ .unwrap(),
+ )
+ .unwrap();
- fn done(&mut self) {}
+ println!("HTML coverage report has been generated at {}", root_report);
+ }
+}
+
+impl HtmlCoverageReporter {
+ /// Collects the coverage summary of each file or directory.
+ pub fn collect_summary(&self) -> CoverageSummary {
+ let urls = self.file_reports.iter().map(|rep| &rep.0.url).collect();
+ let root = util::find_root(urls).unwrap().to_file_path().unwrap();
+ // summary by file or directory
+ // tuple of (line hit, line miss, branch hit, branch miss, parent)
+ let mut summary = HashMap::new();
+ summary.insert("".to_string(), CoverageStats::default()); // root entry
+ for (report, file_text) in &self.file_reports {
+ let path = report.url.to_file_path().unwrap();
+ let relative_path = path.strip_prefix(&root).unwrap();
+ let mut file_text = Some(file_text.to_string());
+
+ let mut summary_path = Some(relative_path);
+ // From leaf to root, adds up the coverage stats
+ while let Some(path) = summary_path {
+ let path_str = path.to_str().unwrap().to_string();
+ let parent = path
+ .parent()
+ .and_then(|p| p.to_str())
+ .map(|p| p.to_string());
+ let stats = summary.entry(path_str).or_insert(CoverageStats {
+ parent,
+ file_text,
+ report: Some(report),
+ ..CoverageStats::default()
+ });
+
+ stats.line_hit += report
+ .found_lines
+ .iter()
+ .filter(|(_, count)| *count > 0)
+ .count();
+ stats.line_miss += report
+ .found_lines
+ .iter()
+ .filter(|(_, count)| *count == 0)
+ .count();
+ stats.branch_hit += report.branches.iter().filter(|b| b.is_hit).count();
+ stats.branch_miss +=
+ report.branches.iter().filter(|b| !b.is_hit).count();
+
+ file_text = None;
+ summary_path = path.parent();
+ }
+ }
+
+ summary
+ }
+
+ /// Gets the report path for a single file
+ pub fn get_report_path(
+ &self,
+ coverage_root: &Path,
+ node: &str,
+ is_dir: bool,
+ ) -> PathBuf {
+ if is_dir {
+ // e.g. /path/to/coverage/html/src/index.html
+ coverage_root.join("html").join(node).join("index.html")
+ } else {
+ // e.g. /path/to/coverage/html/src/main.ts.html
+ Path::new(&format!(
+ "{}.html",
+ coverage_root.join("html").join(node).to_str().unwrap()
+ ))
+ .to_path_buf()
+ }
+ }
+
+ /// Creates single page of html report.
+ pub fn create_html(
+ &self,
+ node: &str,
+ is_dir: bool,
+ stats: &CoverageStats,
+ timestamp: &str,
+ main_content: &str,
+ ) -> String {
+ let title = if node.is_empty() {
+ "Coverage report for all files".to_string()
+ } else {
+ let node = if is_dir {
+ format!("{}/", node)
+ } else {
+ node.to_string()
+ };
+ format!("Coverage report for {node}")
+ };
+ let title = title.replace(std::path::MAIN_SEPARATOR, "/");
+ let head = self.create_html_head(&title);
+ let header = self.create_html_header(&title, stats);
+ let footer = self.create_html_footer(timestamp);
+ format!(
+ "<!doctype html>
+ <html>
+ {head}
+ <body>
+ <div class='wrapper'>
+ {header}
+ <div class='pad1'>
+ {main_content}
+ </div>
+ <div class='push'></div>
+ </div>
+ {footer}
+ </body>
+ </html>"
+ )
+ }
+
+ /// Creates <head> tag for html report.
+ pub fn create_html_head(&self, title: &str) -> String {
+ let style_css = include_str!("style.css");
+ format!(
+ "
+ <head>
+ <meta charset='utf-8'>
+ <title>{title}</title>
+ <style>{style_css}</style>
+ <meta name='viewport' content='width=device-width, initial-scale=1' />
+ </head>"
+ )
+ }
+
+ /// Creates header part of the contents for html report.
+ pub fn create_html_header(
+ &self,
+ title: &str,
+ stats: &CoverageStats,
+ ) -> String {
+ let CoverageStats {
+ line_hit,
+ line_miss,
+ branch_hit,
+ branch_miss,
+ ..
+ } = stats;
+ let (line_total, line_percent, line_class) =
+ util::calc_coverage_display_info(*line_hit, *line_miss);
+ let (branch_total, branch_percent, _) =
+ util::calc_coverage_display_info(*branch_hit, *branch_miss);
+
+ format!(
+ "
+ <div class='pad1'>
+ <h1>{title}</h1>
+ <div class='clearfix'>
+ <div class='fl pad1y space-right2'>
+ <span class='strong'>{branch_percent:.2}%</span>
+ <span class='quiet'>Branches</span>
+ <span class='fraction'>{branch_hit}/{branch_total}</span>
+ </div>
+ <div class='fl pad1y space-right2'>
+ <span class='strong'>{line_percent:.2}%</span>
+ <span class='quiet'>Lines</span>
+ <span class='fraction'>{line_hit}/{line_total}</span>
+ </div>
+ </div>
+ </div>
+ <div class='status-line {line_class}'></div>"
+ )
+ }
+
+ /// Creates footer part of the contents for html report.
+ pub fn create_html_footer(&self, now: &str) -> String {
+ let version = env!("CARGO_PKG_VERSION");
+ format!(
+ "
+ <div class='footer quiet pad2 space-top1 center small'>
+ Code coverage generated by
+ <a href='https://deno.com/' target='_blank'>Deno v{version}</a>
+ at {now}
+ </div>"
+ )
+ }
+
+ /// Creates <table> of summary for html report.
+ pub fn create_html_summary_table(
+ &self,
+ node: &String,
+ summary: &CoverageSummary,
+ ) -> String {
+ let mut children = summary
+ .iter()
+ .filter(|(_, stats)| stats.parent.as_ref() == Some(node))
+ .map(|(k, stats)| (stats.file_text.is_some(), k.clone()))
+ .collect::<Vec<_>>();
+ // Sort directories first, then files
+ children.sort();
+
+ let table_rows: Vec<String> = children.iter().map(|(is_file, c)| {
+ let CoverageStats { line_hit, line_miss, branch_hit, branch_miss, .. } =
+ summary.get(c).unwrap();
+
+ let (line_total, line_percent, line_class) =
+ util::calc_coverage_display_info(*line_hit, *line_miss);
+ let (branch_total, branch_percent, branch_class) =
+ util::calc_coverage_display_info(*branch_hit, *branch_miss);
+
+ let path = Path::new(c.strip_prefix(&format!("{node}{}", std::path::MAIN_SEPARATOR)).unwrap_or(c)).to_str().unwrap();
+ let path = path.replace(std::path::MAIN_SEPARATOR, "/");
+ let path_label = if *is_file { path.to_string() } else { format!("{}/", path) };
+ let path_link = if *is_file { format!("{}.html", path) } else { format!("{}index.html", path_label) };
+
+ format!("
+ <tr>
+ <td class='file {line_class}'><a href='{path_link}'>{path_label}</a></td>
+ <td class='pic {line_class}'>
+ <div class='chart'>
+ <div class='cover-fill' style='width: {line_percent:.1}%'></div><div class='cover-empty' style='width: calc(100% - {line_percent:.1}%)'></div>
+ </div>
+ </td>
+ <td class='pct {branch_class}'>{branch_percent:.2}%</td>
+ <td class='abs {branch_class}'>{branch_hit}/{branch_total}</td>
+ <td class='pct {line_class}'>{line_percent:.2}%</td>
+ <td class='abs {line_class}'>{line_hit}/{line_total}</td>
+ </tr>")}).collect();
+ let table_rows = table_rows.join("\n");
+
+ format!(
+ "
+ <table class='coverage-summary'>
+ <thead>
+ <tr>
+ <th class='file'>File</th>
+ <th class='pic'></th>
+ <th class='pct'>Branches</th>
+ <th class='abs'></th>
+ <th class='pct'>Lines</th>
+ <th class='abs'></th>
+ </tr>
+ </thead>
+ <tbody>
+ {table_rows}
+ </tbody>
+ </table>"
+ )
+ }
+
+ /// Creates <table> of single file code coverage.
+ pub fn create_html_code_table(
+ &self,
+ file_text: &String,
+ report: &CoverageReport,
+ ) -> String {
+ let line_num = file_text.lines().count();
+ let line_count = (1..line_num + 1)
+ .map(|i| format!("<a name='L{i}'></a><a href='#{i}'>{i}</a>"))
+ .collect::<Vec<_>>()
+ .join("\n");
+ let line_coverage = (0..line_num)
+ .map(|i| {
+ if let Some((_, count)) =
+ report.found_lines.iter().find(|(line, _)| i == *line)
+ {
+ if *count == 0 {
+ "<span class='cline-any cline-no'>&nbsp</span>".to_string()
+ } else {
+ format!("<span class='cline-any cline-yes'>x{count}</span>")
+ }
+ } else {
+ "<span class='cline-any cline-neutral'>&nbsp</span>".to_string()
+ }
+ })
+ .collect::<Vec<_>>()
+ .join("\n");
+ let branch_coverage = (0..line_num)
+ .map(|i| {
+ let branch_is_missed = report.branches.iter().any(|b| b.line_index == i && !b.is_hit);
+ if branch_is_missed {
+ "<span class='missing-if-branch' title='branch condition is missed in this line'>I</span>".to_string()
+ } else {
+ "".to_string()
+ }
+ })
+ .collect::<Vec<_>>()
+ .join("\n");
+
+ // TODO(kt3k): Add syntax highlight to source code
+ format!(
+ "<table class='coverage'>
+ <tr>
+ <td class='line-count quiet'><pre>{line_count}</pre></td>
+ <td class='line-coverage quiet'><pre>{line_coverage}</pre></td>
+ <td class='branch-coverage quiet'><pre>{branch_coverage}</pre></td>
+ <td class='text'><pre class='prettyprint'>{file_text}</pre></td>
+ </tr>
+ </table>"
+ )
+ }
}
fn collect_coverages(
@@ -645,6 +1010,11 @@ pub async fn cover_files(
let cli_options = factory.cli_options();
let emitter = factory.emitter()?;
+ assert!(!coverage_flags.files.include.is_empty());
+
+ // Use the first include path as the default output path.
+ let coverage_root = coverage_flags.files.include[0].clone();
+
let script_coverages = collect_coverages(coverage_flags.files)?;
let script_coverages = filter_coverages(
script_coverages,
@@ -665,10 +1035,10 @@ pub async fn cover_files(
vec![]
};
- let reporter_kind = if coverage_flags.lcov {
- CoverageReporterKind::Lcov
- } else {
- CoverageReporterKind::Pretty
+ let reporter_kind = match coverage_flags.r#type {
+ CoverageType::Pretty => CoverageReporterKind::Pretty,
+ CoverageType::Lcov => CoverageReporterKind::Lcov,
+ CoverageType::Html => CoverageReporterKind::Html,
};
let mut reporter = create_reporter(reporter_kind);
@@ -748,7 +1118,7 @@ pub async fn cover_files(
}
}
- reporter.done();
+ reporter.done(&coverage_root);
Ok(())
}
diff --git a/cli/tools/coverage/style.css b/cli/tools/coverage/style.css
new file mode 100644
index 000000000..92ffc5405
--- /dev/null
+++ b/cli/tools/coverage/style.css
@@ -0,0 +1,371 @@
+/* Copyright 2015 the Istanbul contributors. All rights reserved. ISC license. */
+/* Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. */
+
+body,
+html {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+}
+body {
+ font-family:
+ Helvetica Neue,
+ Helvetica,
+ Arial;
+ font-size: 14px;
+ color: #333;
+}
+.small {
+ font-size: 12px;
+}
+*,
+*:after,
+*:before {
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+}
+h1 {
+ font-size: 20px;
+ margin: 0;
+}
+h2 {
+ font-size: 14px;
+}
+pre {
+ font:
+ 12px/1.4 Consolas,
+ "Liberation Mono",
+ Menlo,
+ Courier,
+ monospace;
+ margin: 0;
+ padding: 0;
+ -moz-tab-size: 2;
+ -o-tab-size: 2;
+ tab-size: 2;
+}
+a {
+ color: #0074d9;
+ text-decoration: none;
+}
+a:hover {
+ text-decoration: underline;
+}
+.strong {
+ font-weight: bold;
+}
+.space-top1 {
+ padding: 10px 0 0 0;
+}
+.pad2y {
+ padding: 20px 0;
+}
+.pad1y {
+ padding: 10px 0;
+}
+.pad2x {
+ padding: 0 20px;
+}
+.pad2 {
+ padding: 20px;
+}
+.pad1 {
+ padding: 10px;
+}
+.space-left2 {
+ padding-left: 55px;
+}
+.space-right2 {
+ padding-right: 20px;
+}
+.center {
+ text-align: center;
+}
+.clearfix {
+ display: block;
+}
+.clearfix:after {
+ content: "";
+ display: block;
+ height: 0;
+ clear: both;
+ visibility: hidden;
+}
+.fl {
+ float: left;
+}
+@media only screen and (max-width: 640px) {
+ .col3 {
+ width: 100%;
+ max-width: 100%;
+ }
+ .hide-mobile {
+ display: none !important;
+ }
+}
+
+.quiet {
+ color: #7f7f7f;
+ color: rgba(0, 0, 0, 0.5);
+}
+.quiet a {
+ opacity: 0.7;
+}
+
+.fraction {
+ font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
+ font-size: 10px;
+ color: #555;
+ background: #e8e8e8;
+ padding: 4px 5px;
+ border-radius: 3px;
+ vertical-align: middle;
+}
+
+div.path a:link,
+div.path a:visited {
+ color: #333;
+}
+table.coverage {
+ border-collapse: collapse;
+ margin: 10px 0 0 0;
+ padding: 0;
+}
+
+table.coverage td {
+ margin: 0;
+ padding: 0;
+ vertical-align: top;
+}
+table.coverage td.line-count {
+ text-align: right;
+ padding: 0 5px 0 20px;
+}
+table.coverage td.line-coverage {
+ text-align: right;
+ padding-right: 4px;
+ min-width: 20px;
+}
+
+table.coverage td.branch-coverage {
+ text-align: right;
+ padding-right: 3px;
+ min-width: 8px;
+}
+
+table.coverage td span.cline-any {
+ display: inline-block;
+ padding: 0 5px;
+ width: 100%;
+}
+.missing-if-branch {
+ display: inline-block;
+ margin-right: 5px;
+ border-radius: 3px;
+ position: relative;
+ padding: 0 4px;
+ background: #333;
+ color: yellow;
+}
+
+.skip-if-branch {
+ display: none;
+ margin-right: 10px;
+ position: relative;
+ padding: 0 4px;
+ background: #ccc;
+ color: white;
+}
+.missing-if-branch .typ,
+.skip-if-branch .typ {
+ color: inherit !important;
+}
+.coverage-summary {
+ border-collapse: collapse;
+ width: 100%;
+}
+.coverage-summary tr {
+ border-bottom: 1px solid #bbb;
+}
+.keyline-all {
+ border: 1px solid #ddd;
+}
+.coverage-summary td,
+.coverage-summary th {
+ padding: 10px;
+}
+.coverage-summary tbody {
+ border: 1px solid #bbb;
+}
+.coverage-summary td {
+ border-right: 1px solid #bbb;
+}
+.coverage-summary td:last-child {
+ border-right: none;
+}
+.coverage-summary th {
+ text-align: left;
+ font-weight: normal;
+ white-space: nowrap;
+}
+.coverage-summary th.file {
+ border-right: none !important;
+}
+.coverage-summary th.pct {
+}
+.coverage-summary th.pic,
+.coverage-summary th.abs,
+.coverage-summary td.pct,
+.coverage-summary td.abs {
+ text-align: right;
+}
+.coverage-summary td.file {
+ white-space: nowrap;
+}
+.coverage-summary td.pic {
+ min-width: 120px !important;
+}
+.coverage-summary tfoot td {
+}
+
+.coverage-summary .sorter {
+ height: 10px;
+ width: 7px;
+ display: inline-block;
+ margin-left: 0.5em;
+ background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
+}
+.coverage-summary .sorted .sorter {
+ background-position: 0 -20px;
+}
+.coverage-summary .sorted-desc .sorter {
+ background-position: 0 -10px;
+}
+.status-line {
+ height: 10px;
+}
+/* yellow */
+.cbranch-no {
+ background: yellow !important;
+ color: #111;
+}
+/* dark red */
+.red.solid,
+.status-line.low,
+.low .cover-fill {
+ background: #c21f39;
+}
+.low .chart {
+ border: 1px solid #c21f39;
+}
+.highlighted,
+.highlighted .cstat-no,
+.highlighted .fstat-no,
+.highlighted .cbranch-no {
+ background: #c21f39 !important;
+}
+/* medium red */
+.cstat-no,
+.fstat-no,
+.cbranch-no,
+.cbranch-no {
+ background: #f6c6ce;
+}
+/* light red */
+.low,
+.cline-no {
+ background: #fce1e5;
+}
+/* light green */
+.high,
+.cline-yes {
+ background: rgb(230, 245, 208);
+}
+/* medium green */
+.cstat-yes {
+ background: rgb(161, 215, 106);
+}
+/* dark green */
+.status-line.high,
+.high .cover-fill {
+ background: rgb(77, 146, 33);
+}
+.high .chart {
+ border: 1px solid rgb(77, 146, 33);
+}
+/* dark yellow (gold) */
+.status-line.medium,
+.medium .cover-fill {
+ background: #f9cd0b;
+}
+.medium .chart {
+ border: 1px solid #f9cd0b;
+}
+/* light yellow */
+.medium {
+ background: #fff4c2;
+}
+
+.cstat-skip {
+ background: #ddd;
+ color: #111;
+}
+.fstat-skip {
+ background: #ddd;
+ color: #111 !important;
+}
+.cbranch-skip {
+ background: #ddd !important;
+ color: #111;
+}
+
+span.cline-neutral {
+ background: #eaeaea;
+}
+
+.coverage-summary td.empty {
+ opacity: 0.5;
+ padding-top: 4px;
+ padding-bottom: 4px;
+ line-height: 1;
+ color: #888;
+}
+
+.cover-fill,
+.cover-empty {
+ display: inline-block;
+ height: 12px;
+}
+.chart {
+ line-height: 0;
+}
+.cover-empty {
+ background: white;
+}
+.cover-full {
+ border-right: none !important;
+}
+pre.prettyprint {
+ border: none !important;
+ padding: 0 !important;
+ margin: 0 !important;
+}
+.com {
+ color: #999 !important;
+}
+.ignore-none {
+ color: #999;
+ font-weight: normal;
+}
+
+.wrapper {
+ min-height: 100%;
+ height: auto !important;
+ height: 100%;
+ margin: 0 auto -48px;
+}
+.footer,
+.push {
+ height: 48px;
+}
diff --git a/cli/tools/coverage/util.rs b/cli/tools/coverage/util.rs
new file mode 100644
index 000000000..af986fb23
--- /dev/null
+++ b/cli/tools/coverage/util.rs
@@ -0,0 +1,103 @@
+// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
+
+use deno_core::url::Url;
+
+pub fn find_root(urls: Vec<&Url>) -> Option<Url> {
+ if urls.is_empty() {
+ return None;
+ }
+
+ // Gets the common first part of all the urls.
+ let root = urls[0]
+ .as_ref()
+ .chars()
+ .enumerate()
+ .take_while(|(i, c)| {
+ urls.iter().all(|u| u.as_ref().chars().nth(*i) == Some(*c))
+ })
+ .map(|(_, c)| c)
+ .collect::<String>();
+
+ if let Some(index) = root.rfind('/') {
+ // Removes the basename part if exists.
+ Url::parse(&root[..index + 1]).ok()
+ } else {
+ Url::parse(&root).ok()
+ }
+}
+
+pub fn percent_to_class(percent: f32) -> &'static str {
+ match percent {
+ x if x < 50.0 => "low",
+ x if x < 80.0 => "medium",
+ _ => "high",
+ }
+}
+
+pub fn calc_coverage_display_info(
+ hit: usize,
+ miss: usize,
+) -> (usize, f32, &'static str) {
+ let total = hit + miss;
+ let percent = if total == 0 {
+ 100.0
+ } else {
+ (hit as f32 / total as f32) * 100.0
+ };
+ let class = percent_to_class(percent);
+ (total, percent, class)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_find_root() {
+ let urls = vec![
+ Url::parse("file:///a/b/c/d/e.ts").unwrap(),
+ Url::parse("file:///a/b/c/d/f.ts").unwrap(),
+ Url::parse("file:///a/b/c/d/g.ts").unwrap(),
+ ];
+ let urls = urls.iter().collect();
+ assert_eq!(find_root(urls), Url::parse("file:///a/b/c/d/").ok());
+ }
+
+ #[test]
+ fn test_find_root_empty() {
+ let urls = vec![];
+ assert_eq!(find_root(urls), None);
+ }
+
+ #[test]
+ fn test_find_root_with_similar_filenames() {
+ let urls = vec![
+ Url::parse("file:///a/b/c/d/foo0.ts").unwrap(),
+ Url::parse("file:///a/b/c/d/foo1.ts").unwrap(),
+ Url::parse("file:///a/b/c/d/foo2.ts").unwrap(),
+ ];
+ let urls = urls.iter().collect();
+ assert_eq!(find_root(urls), Url::parse("file:///a/b/c/d/").ok());
+ }
+
+ #[test]
+ fn test_find_root_with_similar_dirnames() {
+ let urls = vec![
+ Url::parse("file:///a/b/c/foo0/mod.ts").unwrap(),
+ Url::parse("file:///a/b/c/foo1/mod.ts").unwrap(),
+ Url::parse("file:///a/b/c/foo2/mod.ts").unwrap(),
+ ];
+ let urls = urls.iter().collect();
+ assert_eq!(find_root(urls), Url::parse("file:///a/b/c/").ok());
+ }
+
+ #[test]
+ fn test_percent_to_class() {
+ assert_eq!(percent_to_class(0.0), "low");
+ assert_eq!(percent_to_class(49.9), "low");
+ assert_eq!(percent_to_class(50.0), "medium");
+ assert_eq!(percent_to_class(79.9), "medium");
+ assert_eq!(percent_to_class(80.0), "high");
+ assert_eq!(percent_to_class(100.0), "high");
+ }
+}