use std::fmt::{self, Write as _};
use std::sync::OnceLock;
use serde::ser::SerializeMap;
use serde::Serialize;
use serde_json::json;
use super::ErrorFormat;
use crate::merge::PrefixMismatch;
static FORMAT: OnceLock<ErrorFormat> = OnceLock::new();
pub fn set_format(format: ErrorFormat) {
let _ = FORMAT.set(format);
}
fn current_format() -> ErrorFormat {
FORMAT.get().copied().unwrap_or_default()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeprecatedFlag {
Format,
FailOnPathMismatch,
}
impl DeprecatedFlag {
const fn name(self) -> &'static str {
match self {
Self::Format => "--format",
Self::FailOnPathMismatch => "--fail-on-path-mismatch",
}
}
const fn replacement(self) -> Option<&'static str> {
match self {
Self::Format => Some("-o/--output"),
Self::FailOnPathMismatch => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Deprecation(DeprecatedFlag);
impl Serialize for Deprecation {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(2))?;
map.serialize_entry("flag", self.0.name())?;
map.serialize_entry("replacement", &self.0.replacement())?;
map.end()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum GlobOrigin {
#[serde(rename = "--include")]
IncludeFlag,
#[serde(rename = "lint-markers.include")]
ConfigInclude,
}
impl GlobOrigin {
pub const fn described(self) -> &'static str {
match self {
Self::IncludeFlag => "--include",
Self::ConfigInclude => "lint-markers.include in config.yaml",
}
}
}
pub(crate) const PATH_MISMATCH_SAMPLE: usize = 3;
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(untagged)]
#[non_exhaustive]
pub enum Warning {
Deprecated(Deprecation),
PathMismatch {
report: String,
file_count: usize,
unmatched: Vec<String>,
},
ShardRoot(PrefixMismatch),
GlobNoMatch {
globs: Vec<String>,
origin: GlobOrigin,
},
}
impl Warning {
pub const fn deprecated(flag: DeprecatedFlag) -> Self {
Self::Deprecated(Deprecation(flag))
}
pub const fn kind(&self) -> &'static str {
match self {
Self::Deprecated(_) => "deprecated",
Self::PathMismatch { .. } => "path-mismatch",
Self::ShardRoot(_) => "shard-root",
Self::GlobNoMatch { .. } => "glob-no-match",
}
}
}
impl fmt::Display for Warning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Deprecated(Deprecation(flag)) => match flag.replacement() {
Some(replacement) => {
write!(f, "{} is deprecated; use {replacement} instead", flag.name())
}
None => write!(
f,
"{} is deprecated and has no effect; a path mismatch is an error unless --allow-path-mismatch or diff.allow-path-mismatch is set",
flag.name()
),
},
Self::PathMismatch {
report,
file_count,
unmatched,
} => {
let mut sample = unmatched
.iter()
.map(|p| format!("`{p}`"))
.collect::<Vec<_>>()
.join(", ");
let more = file_count.saturating_sub(unmatched.len());
if more > 0 {
write!(sample, ", and {more} more")?;
}
write!(
f,
"coverage report {report}: none of its {file_count} file path(s) matches a tracked file in the repository; unmatched normalized paths: {sample}; use --strip-prefix or diff.path-mappings to make paths repo-relative"
)
}
Self::ShardRoot(mismatch) => mismatch.fmt(f),
Self::GlobNoMatch { origin, .. } => write!(
f,
"no tracked file matches the globs in {}; no coverage markers were checked",
origin.described()
),
}
}
}
const LEVEL: &str = "warning";
pub trait Diagnostic: Serialize {
fn level(&self) -> &str;
fn kind(&self) -> &str;
}
#[derive(Serialize)]
struct WarningReport<'a, F = Warning> {
level: &'static str,
kind: &'static str,
message: String,
#[serde(flatten)]
fields: &'a F,
}
impl<F: Serialize> Diagnostic for WarningReport<'_, F> {
fn level(&self) -> &str {
self.level
}
fn kind(&self) -> &str {
self.kind
}
}
pub fn warn(warning: &Warning) {
eprintln!("{}", render(current_format(), warning));
}
pub fn emit(text: impl AsRef<str>, record: &impl Diagnostic) {
eprintln!("{}", render_line(current_format(), text.as_ref(), record));
}
fn render(format: ErrorFormat, warning: &Warning) -> String {
render_line(
format,
&format!("warning: {warning}"),
&WarningReport {
level: LEVEL,
kind: warning.kind(),
message: warning.to_string(),
fields: warning,
},
)
}
pub(super) fn json_line(record: &impl Diagnostic, message: &str) -> String {
json_line_or(record, || json_fallback(record, message))
}
pub(super) fn json_fallback(record: &impl Diagnostic, message: &str) -> serde_json::Value {
json!({ "level": record.level(), "kind": record.kind(), "message": message })
}
pub(super) fn json_line_or(
record: &impl Serialize,
fallback: impl FnOnce() -> serde_json::Value,
) -> String {
serde_json::to_string(record).unwrap_or_else(|_| fallback().to_string())
}
fn render_line(format: ErrorFormat, text: &str, record: &impl Diagnostic) -> String {
match format {
ErrorFormat::Text => text.to_string(),
ErrorFormat::Json => json_line(record, text),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::cli::doc_fields::{
assert_every_variant, check_fields, example_keys, example_line, first_table_rows,
json_keys, kind_rows, reference_section,
};
fn path_mismatch() -> Warning {
Warning::PathMismatch {
report: "head.lcov".to_owned(),
file_count: 4,
unmatched: vec!["a/x.rs".to_owned(), "a/y.rs".to_owned()],
}
}
#[test]
fn path_mismatch_reference_example_matches_display() {
#[derive(serde::Deserialize)]
struct Example {
report: String,
file_count: usize,
unmatched: Vec<String>,
message: String,
}
let line = example_line(reference_section("Warnings"), "path-mismatch")
.expect("docs/reference.md: missing path-mismatch JSON example");
let example: Example = serde_json::from_str(line).unwrap();
let warning = Warning::PathMismatch {
report: example.report,
file_count: example.file_count,
unmatched: example.unmatched,
};
assert_eq!(
example.message,
warning.to_string(),
"docs/reference.md: path-mismatch message differs from Display"
);
}
const VARIANTS: usize = 4;
const fn variant_ordinal(warning: &Warning) -> usize {
match warning {
Warning::Deprecated(_) => 0,
Warning::PathMismatch { .. } => 1,
Warning::ShardRoot(_) => 2,
Warning::GlobNoMatch { .. } => 3,
}
}
fn glob_no_match(origin: GlobOrigin) -> Warning {
Warning::GlobNoMatch {
globs: vec!["**/*.nomatch".to_owned(), "docs/*.x".to_owned()],
origin,
}
}
fn shard_root() -> Warning {
Warning::ShardRoot(PrefixMismatch {
shard: "two.lcov".to_owned(),
strip_prefix: "/ci/workspace".to_owned(),
absolute_paths: 3,
})
}
#[test]
fn text_is_the_warning_line() {
let line = render(
ErrorFormat::Text,
&Warning::deprecated(DeprecatedFlag::Format),
);
assert_eq!(
line,
"warning: --format is deprecated; use -o/--output instead"
);
}
#[derive(Serialize)]
struct Record {
level: &'static str,
n: Option<u32>,
message: &'static str,
}
#[derive(Serialize)]
#[serde(transparent)]
struct Labelled<T>(T);
impl<T: Serialize> Diagnostic for Labelled<T> {
fn level(&self) -> &'static str {
"info"
}
fn kind(&self) -> &'static str {
"thing"
}
}
#[test]
fn emit_text_is_the_text_untouched() {
let record = Record {
level: "info",
n: None,
message: "ignored",
};
assert_eq!(
render_line(ErrorFormat::Text, "as is: 1", &Labelled(record)),
"as is: 1"
);
}
#[test]
fn emit_json_is_the_record_on_one_line() {
let record = Record {
level: "info",
n: None,
message: "two\nlines",
};
let line = render_line(ErrorFormat::Json, "unused", &Labelled(record));
assert_eq!(line, r#"{"level":"info","n":null,"message":"two\nlines"}"#);
}
#[test]
fn json_line_or_skips_fallback_on_success() {
let record = Record {
level: "info",
n: Some(1),
message: "two\nlines",
};
let line = json_line_or(&record, || panic!("fallback called on success"));
assert_eq!(line, r#"{"level":"info","n":1,"message":"two\nlines"}"#);
}
#[test]
fn json_line_or_calls_fallback_once_on_failure() {
let mut calls = 0;
let line = json_line_or(&Fails, || {
calls += 1;
json!({ "message": "bad \"record\"\nline" })
});
assert_eq!(calls, 1);
assert_eq!(line, r#"{"message":"bad \"record\"\nline"}"#);
}
#[test]
fn messages_are_unchanged() {
assert_eq!(
Warning::deprecated(DeprecatedFlag::FailOnPathMismatch).to_string(),
"--fail-on-path-mismatch is deprecated and has no effect; a path mismatch is an error unless --allow-path-mismatch or diff.allow-path-mismatch is set"
);
assert_eq!(
path_mismatch().to_string(),
"coverage report head.lcov: none of its 4 file path(s) matches a tracked file in the repository; unmatched normalized paths: `a/x.rs`, `a/y.rs`, and 2 more; use --strip-prefix or diff.path-mappings to make paths repo-relative"
);
let complete = Warning::PathMismatch {
report: "head.lcov".to_owned(),
file_count: 2,
unmatched: vec!["a/x.rs".to_owned(), "a/y.rs".to_owned()],
};
assert!(
complete
.to_string()
.contains("normalized paths: `a/x.rs`, `a/y.rs`; use "),
"{complete}"
);
assert!(shard_root().to_string().starts_with(
"coverage shard two.lcov: none of its 3 absolute file path(s) is under `/ci/workspace`, so "
));
assert_eq!(
glob_no_match(GlobOrigin::IncludeFlag).to_string(),
"no tracked file matches the globs in --include; no coverage markers were checked"
);
assert_eq!(
glob_no_match(GlobOrigin::ConfigInclude).to_string(),
"no tracked file matches the globs in lint-markers.include in config.yaml; no coverage markers were checked"
);
}
#[test]
fn json_is_one_line_in_field_order() {
let warning = Warning::PathMismatch {
report: "it said \"no\"\nreally".to_owned(),
file_count: 1,
unmatched: vec!["x".to_owned()],
};
let line = render(ErrorFormat::Json, &warning);
assert!(!line.contains('\n'), "{line}");
assert!(
line.starts_with(r#"{"level":"warning","kind":"path-mismatch","message":"coverage report it said \"no\"\nreally:"#),
"{line}"
);
assert!(
line.ends_with(
r#""report":"it said \"no\"\nreally","file_count":1,"unmatched":["x"]}"#
),
"{line}"
);
let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
assert_eq!(parsed["report"], "it said \"no\"\nreally");
}
#[test]
fn each_kind_has_its_fields() {
let cases = [
(
Warning::deprecated(DeprecatedFlag::Format),
"deprecated",
r#"{"flag":"--format","replacement":"-o/--output"}"#,
),
(
Warning::deprecated(DeprecatedFlag::FailOnPathMismatch),
"deprecated",
r#"{"flag":"--fail-on-path-mismatch","replacement":null}"#,
),
(
path_mismatch(),
"path-mismatch",
r#"{"report":"head.lcov","file_count":4,"unmatched":["a/x.rs","a/y.rs"]}"#,
),
(
shard_root(),
"shard-root",
r#"{"shard":"two.lcov","strip_prefix":"/ci/workspace","absolute_paths":3}"#,
),
(
glob_no_match(GlobOrigin::IncludeFlag),
"glob-no-match",
r#"{"globs":["**/*.nomatch","docs/*.x"],"origin":"--include"}"#,
),
(
glob_no_match(GlobOrigin::ConfigInclude),
"glob-no-match",
r#"{"globs":["**/*.nomatch","docs/*.x"],"origin":"lint-markers.include"}"#,
),
];
assert_every_variant(
cases.iter().map(|(warning, ..)| variant_ordinal(warning)),
VARIANTS,
"Warning",
);
for (warning, kind, fields) in cases {
assert_eq!(warning.kind(), kind, "{warning:?}");
assert_eq!(serde_json::to_string(&warning).unwrap(), fields);
let object: serde_json::Value =
serde_json::from_str(&render(ErrorFormat::Json, &warning)).unwrap();
let expected: serde_json::Value = serde_json::from_str(fields).unwrap();
let mut keys: Vec<_> = object.as_object().unwrap().keys().cloned().collect();
keys.sort();
let mut want: Vec<_> = expected.as_object().unwrap().keys().cloned().collect();
want.extend(["level", "kind", "message"].map(String::from));
want.sort();
assert_eq!(keys, want, "{warning:?}");
assert_eq!(object["level"], "warning");
assert_eq!(object["kind"], kind);
assert_eq!(object["message"], warning.to_string());
for (key, value) in expected.as_object().unwrap() {
assert_eq!(&object[key], value, "{key}");
}
}
}
#[test]
fn each_kind_matches_the_docs() {
let section = reference_section("Warnings");
let common = ["level", "kind", "message"];
let common_rows = first_table_rows(section);
let names: Vec<_> = common_rows.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, common);
let warnings = [
Warning::deprecated(DeprecatedFlag::Format),
Warning::deprecated(DeprecatedFlag::FailOnPathMismatch),
path_mismatch(),
shard_root(),
glob_no_match(GlobOrigin::IncludeFlag),
glob_no_match(GlobOrigin::ConfigInclude),
];
assert_every_variant(warnings.iter().map(variant_ordinal), VARIANTS, "Warning");
let documented = kind_rows(section);
let mut kinds: Vec<_> = warnings.iter().map(Warning::kind).collect();
kinds.sort_unstable();
kinds.dedup();
let mut listed: Vec<_> = documented.iter().map(|(kind, _)| kind.as_str()).collect();
listed.sort_unstable();
assert_eq!(listed, kinds, "the kinds in the docs table");
let mut examples = 0;
for warning in &warnings {
let kind = warning.kind();
let line = render(ErrorFormat::Json, warning);
let keys = json_keys(&line);
let value = serde_json::from_str(&line).unwrap();
check_fields(&common_rows, &value, kind);
let fields = &documented.iter().find(|(k, _)| k == kind).unwrap().1;
check_fields(fields, &value, kind);
let want: Vec<_> = common
.iter()
.map(ToString::to_string)
.chain(fields.iter().map(|field| field.name.clone()))
.collect();
assert_eq!(keys, want, "{kind}: the docs table");
if let Some(example) = example_keys(section, kind) {
examples += 1;
assert_eq!(example, want, "{kind}: the docs example");
}
}
assert!(examples > 0, "the docs show no example warning line");
}
struct Fails;
impl Serialize for Fails {
fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
Err(serde::ser::Error::custom("not serializable"))
}
}
#[test]
fn emit_json_falls_back_to_the_text_when_the_record_fails() {
let line = render_line(ErrorFormat::Json, "two \"quoted\"\nlines", &Labelled(Fails));
assert_eq!(
line,
r#"{"kind":"thing","level":"info","message":"two \"quoted\"\nlines"}"#
);
}
#[test]
fn json_line_prefers_the_record() {
let record = Record {
level: "info",
n: Some(1),
message: "m",
};
let line = json_line(&Labelled(record), "unused");
assert_eq!(line, r#"{"level":"info","n":1,"message":"m"}"#);
}
#[test]
fn warning_report_names_its_own_level_and_kind() {
let warning = path_mismatch();
let report = WarningReport {
level: LEVEL,
kind: warning.kind(),
message: warning.to_string(),
fields: &warning,
};
let object = serde_json::to_value(&report).unwrap();
assert_eq!(object["level"], report.level());
assert_eq!(object["kind"], report.kind());
assert_eq!(report.level(), "warning");
assert_eq!(report.kind(), "path-mismatch");
}
#[test]
fn warning_json_falls_back_to_level_kind_and_message() {
let warning = path_mismatch();
let line = render_line(
ErrorFormat::Json,
&warning.to_string(),
&WarningReport {
level: LEVEL,
kind: warning.kind(),
message: warning.to_string(),
fields: &Fails,
},
);
let object: serde_json::Value = serde_json::from_str(&line).unwrap();
assert_eq!(object["level"], "warning");
assert_eq!(object["kind"], "path-mismatch");
assert_eq!(object["message"], warning.to_string());
assert_eq!(object.as_object().unwrap().len(), 3);
}
}