#![allow(clippy::doc_markdown)]
use std::cmp::Ordering;
use std::fmt;
use std::sync::LazyLock;
use crate::Metric;
use crate::spaces::SpaceKind;
use crate::suggestion::{closest_names, render_suggestion};
mod threshold;
pub use threshold::{Change, Checked, LimitError, LimitWarning, Threshold};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MetricScope {
File,
Function,
Container,
}
impl MetricScope {
#[must_use]
pub fn admits(self, kind: SpaceKind) -> bool {
match self {
Self::File => matches!(kind, SpaceKind::Unit),
Self::Function => matches!(kind, SpaceKind::Function),
Self::Container => matches!(
kind,
SpaceKind::Class
| SpaceKind::Struct
| SpaceKind::Trait
| SpaceKind::Impl
| SpaceKind::Namespace
| SpaceKind::Interface
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Direction {
HigherIsWorse,
LowerIsWorse,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum Scale {
Ratio,
#[non_exhaustive]
Interval {
min: f64,
max: f64,
unit: f64,
},
}
const MI_VISUAL_STUDIO_UNIT: f64 = 10.0;
const MI_ORIGINAL_UNIT: f64 = MI_VISUAL_STUDIO_UNIT * 171.0 / 100.0;
const MI_UNBOUNDED: Scale = Scale::Interval {
min: f64::NEG_INFINITY,
max: f64::INFINITY,
unit: MI_ORIGINAL_UNIT,
};
const MI_PERCENT: Scale = Scale::Interval {
min: 0.0,
max: 100.0,
unit: MI_VISUAL_STUDIO_UNIT,
};
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct MetricInfo {
pub id: &'static str,
pub family: &'static str,
pub long_description: &'static str,
pub direction: Direction,
pub skip_at_unit: bool,
pub scope: MetricScope,
pub scale: Scale,
}
impl MetricInfo {
#[must_use]
pub fn cmp_worse(&self, a: f64, b: f64) -> Ordering {
let numeric = |x: f64, y: f64| x.partial_cmp(&y).unwrap_or(Ordering::Equal);
match (a.is_nan(), b.is_nan()) {
(true, true) => Ordering::Equal,
(true, false) => Ordering::Greater,
(false, true) => Ordering::Less,
(false, false) => match self.direction {
Direction::HigherIsWorse => numeric(a, b),
Direction::LowerIsWorse => numeric(b, a),
},
}
}
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct MetricRow {
pub name: &'static str,
pub summary: &'static str,
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct MetricFamily {
pub name: &'static str,
pub rows: &'static [MetricRow],
}
#[rustfmt::skip]
pub const METRICS: &[MetricInfo] = &[
MetricInfo { id: "cognitive", family: "cognitive", long_description: "Cognitive Complexity exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: true, scope: MetricScope::Function, scale: Scale::Ratio },
MetricInfo { id: "cyclomatic", family: "cyclomatic", long_description: "Cyclomatic Complexity exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: true, scope: MetricScope::Function, scale: Scale::Ratio },
MetricInfo { id: "cyclomatic.modified", family: "cyclomatic", long_description: "Modified Cyclomatic Complexity exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: true, scope: MetricScope::Function, scale: Scale::Ratio },
MetricInfo { id: "halstead.volume", family: "halstead", long_description: "Halstead volume exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: Scale::Ratio },
MetricInfo { id: "halstead.difficulty", family: "halstead", long_description: "Halstead difficulty exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: Scale::Ratio },
MetricInfo { id: "halstead.effort", family: "halstead", long_description: "Halstead effort exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: Scale::Ratio },
MetricInfo { id: "halstead.time", family: "halstead", long_description: "Halstead time-to-program exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: Scale::Ratio },
MetricInfo { id: "halstead.bugs", family: "halstead", long_description: "Estimated Halstead bugs exceed the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: Scale::Ratio },
MetricInfo { id: "loc.sloc", family: "loc", long_description: "Source lines of code exceed the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::File, scale: Scale::Ratio },
MetricInfo { id: "loc.ploc", family: "loc", long_description: "Physical lines of code exceed the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::File, scale: Scale::Ratio },
MetricInfo { id: "loc.lloc", family: "loc", long_description: "Logical lines of code exceed the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::File, scale: Scale::Ratio },
MetricInfo { id: "loc.cloc", family: "loc", long_description: "Comment lines of code exceed the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::File, scale: Scale::Ratio },
MetricInfo { id: "loc.blank", family: "loc", long_description: "Blank lines of code exceed the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::File, scale: Scale::Ratio },
MetricInfo { id: "loc.dloc", family: "loc", long_description: "Documentation lines of code exceed the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::File, scale: Scale::Ratio },
MetricInfo { id: "nom", family: "nom", long_description: "Number of methods/functions exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Container, scale: Scale::Ratio },
MetricInfo { id: "tokens", family: "tokens", long_description: "Number of tokens exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: Scale::Ratio },
MetricInfo { id: "nexits", family: "nexits", long_description: "Number of exit points exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: Scale::Ratio },
MetricInfo { id: "nargs", family: "nargs", long_description: "Number of function arguments exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: true, scope: MetricScope::Function, scale: Scale::Ratio },
MetricInfo { id: "mi.original", family: "mi", long_description: "Maintainability Index falls below the configured threshold.", direction: Direction::LowerIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: MI_UNBOUNDED },
MetricInfo { id: "mi.sei", family: "mi", long_description: "Maintainability Index (SEI) falls below the configured threshold.", direction: Direction::LowerIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: MI_UNBOUNDED },
MetricInfo { id: "mi.visual_studio", family: "mi", long_description: "Maintainability Index (Visual Studio) falls below the configured threshold.", direction: Direction::LowerIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: MI_PERCENT },
MetricInfo { id: "abc", family: "abc", long_description: "ABC magnitude exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: true, scope: MetricScope::Function, scale: Scale::Ratio },
MetricInfo { id: "wmc", family: "wmc", long_description: "Weighted Methods per Class exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Container, scale: Scale::Ratio },
MetricInfo { id: "npm", family: "npm", long_description: "Number of public methods exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Container, scale: Scale::Ratio },
MetricInfo { id: "npa", family: "npa", long_description: "Number of public attributes exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Container, scale: Scale::Ratio },
];
pub const FAMILIES: &[MetricFamily] = &[
MetricFamily {
name: "cognitive",
rows: &[MetricRow {
name: "cognitive",
summary: "Cognitive Complexity: how difficult code is to understand.",
}],
},
MetricFamily {
name: "cyclomatic",
rows: &[MetricRow {
name: "cyclomatic",
summary: "Cyclomatic Complexity: linearly independent paths through the code; the modified variant collapses switch/match/when arms in a single switch statement into one decision point.",
}],
},
MetricFamily {
name: "halstead",
rows: &[MetricRow {
name: "halstead",
summary: "Halstead suite: vocabulary, length, volume, difficulty, effort, time, bugs.",
}],
},
MetricFamily {
name: "loc",
rows: &[
MetricRow {
name: "sloc",
summary: "Source lines of code: total lines in a source file.",
},
MetricRow {
name: "ploc",
summary: "Physical lines of code: instruction lines.",
},
MetricRow {
name: "lloc",
summary: "Logical lines of code: statement count.",
},
MetricRow {
name: "cloc",
summary: "Comment lines of code.",
},
MetricRow {
name: "blank",
summary: "Blank lines.",
},
MetricRow {
name: "dloc",
summary: "Documentation lines of code: doc comments, docstrings and POD, a subset of cloc.",
},
],
},
MetricFamily {
name: "nom",
rows: &[MetricRow {
name: "nom",
summary: "Number of methods and closures.",
}],
},
MetricFamily {
name: "tokens",
rows: &[MetricRow {
name: "tokens",
summary: "Per-function token count: AST leaves excluding comments.",
}],
},
MetricFamily {
name: "nexits",
rows: &[MetricRow {
name: "nexits",
summary: "Number of exit points from a function or method.",
}],
},
MetricFamily {
name: "nargs",
rows: &[MetricRow {
name: "nargs",
summary: "Number of arguments to a function or method.",
}],
},
MetricFamily {
name: "mi",
rows: &[MetricRow {
name: "mi",
summary: "Maintainability Index suite.",
}],
},
MetricFamily {
name: "abc",
rows: &[MetricRow {
name: "abc",
summary: "ABC: assignments, branches, and conditions.",
}],
},
MetricFamily {
name: "wmc",
rows: &[MetricRow {
name: "wmc",
summary: "Weighted Methods per Class.",
}],
},
MetricFamily {
name: "npm",
rows: &[MetricRow {
name: "npm",
summary: "Number of public methods of a class.",
}],
},
MetricFamily {
name: "npa",
rows: &[MetricRow {
name: "npa",
summary: "Number of public attributes of a class.",
}],
},
];
#[must_use]
pub fn lookup(id: &str) -> Option<&'static MetricInfo> {
METRICS.iter().find(|m| m.id == id)
}
#[deprecated(
since = "2.4.0",
note = "read `MetricInfo::direction` via `lookup`, or build a `Threshold`"
)]
#[must_use]
pub fn lower_is_worse(id: &str) -> bool {
lookup(id).is_some_and(|m| matches!(m.direction, Direction::LowerIsWorse))
}
#[must_use]
pub fn scope(id: &str) -> Option<MetricScope> {
lookup(id).map(|m| m.scope)
}
#[must_use]
pub fn scale(id: &str) -> Option<Scale> {
lookup(id).map(|m| m.scale)
}
#[must_use]
pub fn selector_names() -> &'static [&'static str] {
static NAMES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
let mut names: Vec<&'static str> = FAMILIES
.iter()
.flat_map(|family| {
std::iter::once(family.name).chain(family.rows.iter().map(|row| row.name))
})
.collect();
names.sort_unstable();
names.dedup();
names
});
&NAMES
}
pub fn resolve_selector(name: &str) -> Result<Metric, UnknownSelectorError> {
FAMILIES
.iter()
.find(|family| family.name == name || family.rows.iter().any(|row| row.name == name))
.map(|family| family.name)
.or_else(|| lookup(name).map(|info| info.family))
.and_then(|family| family.parse::<Metric>().ok())
.ok_or_else(|| UnknownSelectorError::new(name))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownSelectorError {
input: String,
suggestions: Vec<&'static str>,
}
impl UnknownSelectorError {
fn new(input: &str) -> Self {
Self {
input: input.to_owned(),
suggestions: closest_names(input, selector_names()),
}
}
#[must_use]
pub fn input(&self) -> &str {
&self.input
}
#[must_use]
pub fn suggestions(&self) -> &[&'static str] {
&self.suggestions
}
}
impl fmt::Display for UnknownSelectorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"unknown metric: {}{}; known metrics: {}",
self.input,
render_suggestion(&self.suggestions),
selector_names().join(", ")
)
}
}
impl std::error::Error for UnknownSelectorError {}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn metric_ids_are_unique() {
let mut seen = HashSet::new();
for m in METRICS {
assert!(seen.insert(m.id), "duplicate metric id {:?}", m.id);
}
}
#[test]
fn family_names_are_unique() {
let mut seen = HashSet::new();
for f in FAMILIES {
assert!(seen.insert(f.name), "duplicate family name {:?}", f.name);
}
}
#[test]
fn every_metric_family_is_declared() {
let families: HashSet<&str> = FAMILIES.iter().map(|f| f.name).collect();
for m in METRICS {
assert!(
families.contains(m.family),
"metric {:?} references undeclared family {:?}",
m.id,
m.family,
);
}
}
#[test]
fn every_family_has_a_metric() {
let metric_families: HashSet<&str> = METRICS.iter().map(|m| m.family).collect();
for f in FAMILIES {
assert!(
metric_families.contains(f.name),
"family {:?} has no METRICS entry",
f.name,
);
}
}
#[test]
fn lookup_round_trips_and_rejects_unknown() {
for m in METRICS {
assert_eq!(lookup(m.id).map(|i| i.id), Some(m.id));
}
assert!(lookup("not.a.metric").is_none());
}
#[test]
fn lower_is_worse_iff_mi_family() {
for m in METRICS {
let expect_lower = m.family == "mi";
assert_eq!(
matches!(m.direction, Direction::LowerIsWorse),
expect_lower,
"metric {:?} has the wrong direction",
m.id,
);
}
}
#[allow(deprecated, clippy::disallowed_methods)]
#[test]
fn lower_is_worse_helper_matches_catalog_and_defaults_false() {
assert!(lower_is_worse("mi.original"), "mi.* is lower-is-worse");
assert!(
!lower_is_worse("cyclomatic"),
"cyclomatic is higher-is-worse"
);
assert!(!lower_is_worse("not_a_metric"));
}
#[test]
fn sentence_phrasing_matches_direction() {
for m in METRICS {
match m.direction {
Direction::LowerIsWorse => assert!(
m.long_description.contains("falls below"),
"{:?} should use `falls below`: {:?}",
m.id,
m.long_description,
),
Direction::HigherIsWorse => assert!(
m.long_description.contains("exceed"),
"{:?} should use `exceed(s)`: {:?}",
m.id,
m.long_description,
),
}
}
}
#[test]
fn skip_at_unit_is_the_sum_vs_per_space_divergence_set() {
let mut skip: Vec<&str> = METRICS
.iter()
.filter(|m| m.skip_at_unit)
.map(|m| m.id)
.collect();
skip.sort_unstable();
assert_eq!(
skip,
[
"abc",
"cognitive",
"cyclomatic",
"cyclomatic.modified",
"nargs"
],
"skip_at_unit set drifted from the JSON-aggregate-vs-CLI-accessor \
property; review against the CLI EXTRACTORS accessors before editing",
);
}
#[test]
fn scope_partitions_metrics_by_measured_kind() {
let by_scope = |want: MetricScope| {
let mut ids: Vec<&str> = METRICS
.iter()
.filter(|m| m.scope == want)
.map(|m| m.id)
.collect();
ids.sort_unstable();
ids
};
assert_eq!(
by_scope(MetricScope::File),
[
"loc.blank",
"loc.cloc",
"loc.dloc",
"loc.lloc",
"loc.ploc",
"loc.sloc"
],
"only the loc.* size family is File-scoped",
);
assert_eq!(
by_scope(MetricScope::Container),
["nom", "npa", "npm", "wmc"],
"only the OO size metrics are Container-scoped",
);
for id in [
"cognitive",
"cyclomatic",
"halstead.effort",
"nargs",
"nexits",
"abc",
"mi.original",
] {
assert_eq!(
scope(id),
Some(MetricScope::Function),
"{id} should be Function-scoped"
);
}
let counted = by_scope(MetricScope::File).len()
+ by_scope(MetricScope::Function).len()
+ by_scope(MetricScope::Container).len();
assert_eq!(
counted,
METRICS.len(),
"every metric must have exactly one scope"
);
}
#[test]
#[allow(clippy::float_cmp)] fn scale_is_interval_for_exactly_the_mi_family() {
let mut interval: Vec<&str> = Vec::new();
for m in METRICS {
match m.scale {
Scale::Interval { min, max, unit } => {
assert_eq!(m.family, "mi", "{} is interval-scale", m.id);
assert!(unit.is_finite() && unit > 0.0, "{} unit {unit}", m.id);
assert!(min <= max, "{} domain [{min}, {max}]", m.id);
interval.push(m.id);
}
Scale::Ratio => assert_ne!(m.family, "mi", "{} is ratio-scale", m.id),
}
}
interval.sort_unstable();
assert_eq!(interval, ["mi.original", "mi.sei", "mi.visual_studio"]);
assert_eq!(MI_ORIGINAL_UNIT, 17.1);
for id in ["mi.original", "mi.sei"] {
assert_eq!(
scale(id),
Some(Scale::Interval {
min: f64::NEG_INFINITY,
max: f64::INFINITY,
unit: 17.1
}),
"{id}"
);
}
assert_eq!(
scale("mi.visual_studio"),
Some(Scale::Interval {
min: 0.0,
max: 100.0,
unit: 10.0
})
);
}
#[test]
fn cmp_worse_orients_by_direction() {
let cyc = lookup("cyclomatic").expect("known");
assert_eq!(cyc.cmp_worse(20.0, 10.0), Ordering::Greater);
assert_eq!(cyc.cmp_worse(10.0, 20.0), Ordering::Less);
assert_eq!(cyc.cmp_worse(3.0, 3.0), Ordering::Equal);
let mi = lookup("mi.original").expect("known");
assert_eq!(mi.cmp_worse(-20.0, 10.0), Ordering::Greater);
assert_eq!(mi.cmp_worse(10.0, -20.0), Ordering::Less);
for info in [cyc, mi] {
assert_eq!(info.cmp_worse(-0.0, 0.0), Ordering::Equal, "{}", info.id);
assert_eq!(info.cmp_worse(0.0, -0.0), Ordering::Equal, "{}", info.id);
}
}
#[test]
fn cmp_worse_ranks_nan_worst_in_both_directions() {
for id in ["cyclomatic", "mi.original"] {
let info = lookup(id).expect("known");
for finite in [f64::NEG_INFINITY, -5.0, 0.0, 1e9, f64::INFINITY] {
assert_eq!(info.cmp_worse(f64::NAN, finite), Ordering::Greater, "{id}");
assert_eq!(info.cmp_worse(finite, f64::NAN), Ordering::Less, "{id}");
}
assert_eq!(info.cmp_worse(f64::NAN, -f64::NAN), Ordering::Equal, "{id}");
let mut values = [3.0, f64::NAN, -2.0, 7.0];
values.sort_by(|a, b| info.cmp_worse(*a, *b));
assert!(values[3].is_nan(), "{id}: {values:?}");
}
}
#[test]
fn scope_admits_only_its_kinds() {
assert!(MetricScope::File.admits(SpaceKind::Unit));
assert!(!MetricScope::File.admits(SpaceKind::Function));
assert!(!MetricScope::File.admits(SpaceKind::Class));
assert!(MetricScope::Function.admits(SpaceKind::Function));
assert!(!MetricScope::Function.admits(SpaceKind::Unit));
assert!(!MetricScope::Function.admits(SpaceKind::Impl));
for kind in [
SpaceKind::Class,
SpaceKind::Struct,
SpaceKind::Trait,
SpaceKind::Impl,
SpaceKind::Namespace,
SpaceKind::Interface,
] {
assert!(
MetricScope::Container.admits(kind),
"{kind:?} is a container"
);
}
assert!(!MetricScope::Container.admits(SpaceKind::Unit));
assert!(!MetricScope::Container.admits(SpaceKind::Function));
for scope in [
MetricScope::File,
MetricScope::Function,
MetricScope::Container,
] {
assert!(
!scope.admits(SpaceKind::Unknown),
"{scope:?} must not admit Unknown"
);
}
}
#[test]
fn selector_names_are_families_plus_rows() {
assert_eq!(
selector_names(),
[
"abc",
"blank",
"cloc",
"cognitive",
"cyclomatic",
"dloc",
"halstead",
"lloc",
"loc",
"mi",
"nargs",
"nexits",
"nom",
"npa",
"npm",
"ploc",
"sloc",
"tokens",
"wmc",
]
);
}
#[test]
fn every_selector_name_resolves() {
for name in selector_names() {
assert!(resolve_selector(name).is_ok(), "{name} must resolve");
}
}
#[test]
fn resolve_selector_takes_family_names() {
assert_eq!(resolve_selector("loc"), Ok(Metric::Loc));
assert_eq!(resolve_selector("cyclomatic"), Ok(Metric::Cyclomatic));
for name in Metric::NAMES {
assert_eq!(
resolve_selector(name).map(|m| m.to_string()),
Ok((*name).to_owned())
);
}
}
#[test]
fn resolve_selector_maps_row_names_to_their_family() {
for row in ["sloc", "ploc", "lloc", "cloc", "blank", "dloc"] {
assert_eq!(resolve_selector(row), Ok(Metric::Loc), "{row}");
}
}
#[test]
fn resolve_selector_maps_dotted_ids_to_their_family() {
assert_eq!(
resolve_selector("cyclomatic.modified"),
Ok(Metric::Cyclomatic)
);
assert_eq!(resolve_selector("halstead.volume"), Ok(Metric::Halstead));
assert_eq!(resolve_selector("mi.original"), Ok(Metric::Mi));
assert_eq!(resolve_selector("loc.dloc"), Ok(Metric::Loc));
for info in METRICS {
assert_eq!(
resolve_selector(info.id).map(|m| m.to_string()),
Ok(info.family.to_owned()),
"{}",
info.id
);
}
}
#[test]
fn unknown_selector_suggests_the_closest_name() {
let err = resolve_selector("dlocc").expect_err("not a selector");
assert_eq!(err.input(), "dlocc");
assert_eq!(err.suggestions(), ["dloc"]);
assert_eq!(
err.to_string(),
"unknown metric: dlocc; did you mean `dloc`?; known metrics: abc, blank, cloc, \
cognitive, cyclomatic, dloc, halstead, lloc, loc, mi, nargs, nexits, nom, npa, \
npm, ploc, sloc, tokens, wmc"
);
}
#[test]
fn unknown_selector_lists_tied_suggestions() {
let err = resolve_selector("npz").expect_err("not a selector");
assert_eq!(err.suggestions(), ["npa", "npm"]);
assert!(
err.to_string()
.starts_with("unknown metric: npz; did you mean one of `npa`, `npm`?; known"),
"{err}"
);
}
#[test]
fn unknown_selector_without_a_close_name_omits_the_hint() {
let err = resolve_selector("xyzq").expect_err("not a selector");
assert_eq!(err.suggestions(), [] as [&str; 0]);
assert_eq!(
err.to_string(),
format!(
"unknown metric: xyzq; known metrics: {}",
selector_names().join(", ")
)
);
}
#[test]
fn resolve_selector_is_case_sensitive() {
let err = resolve_selector("Loc").expect_err("case-sensitive");
assert_eq!(err.suggestions(), ["loc"]);
}
}