big_code_analysis/metric_catalog.rs
1//! Single source of truth for the metric catalog.
2//!
3//! Before this module existed, the same set of offender metric ids was
4//! hand-maintained in three places — [`output::sarif`]'s rule
5//! descriptions, the CLI's threshold extractor table, and a third copy
6//! inside a "does every extractor have a description" test — plus a
7//! fourth, differently-shaped table powering `bca list-metrics`. Those
8//! tables drifted: ten of twenty-one rule-description keys once failed
9//! to match any real offender id and went unnoticed for two model
10//! versions.
11//!
12//! [`METRICS`](crate::metric_catalog::METRICS) is now the canonical
13//! list of offender sub-metric ids (`halstead.volume`, `mi.original`,
14//! …) together with their long-form sentences and
15//! [`Direction`](crate::metric_catalog::Direction).
16//! [`FAMILIES`](crate::metric_catalog::FAMILIES) is the canonical view
17//! that `bca list-metrics` renders. The library's offender formatters
18//! ([`output::sarif`], [`output::code_climate`]) read `METRICS`; the
19//! CLI's threshold engine keys its extractor table off the same ids and
20//! a parity test pins the two id-sets together, so a new metric cannot
21//! ship with a half-updated catalog.
22//!
23//! [`output::sarif`]: crate::output
24//! [`output::code_climate`]: crate::output
25
26#![allow(clippy::doc_markdown)]
27
28use crate::spaces::SpaceKind;
29
30/// The space kind a metric's threshold is meaningful on (issue #969).
31///
32/// A threshold gate (`bca check`, the Python `to_sarif` binding) walks
33/// every [`crate::FuncSpace`] — the file-level [`SpaceKind::Unit`] root,
34/// every container (class / impl / ...), and every individual function.
35/// For the subtree-summed accessors a metric's value at any space that
36/// owns children is a *sum across many functions*, so a per-function
37/// limit would fire on every non-trivial file and multi-method `impl`.
38/// Scope records the kind each metric actually measures so the front-ends
39/// gate it there and nowhere else — keeping the CLI gate and the binding
40/// in lockstep, the same way [`Direction`] keeps their breach direction
41/// aligned.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub enum MetricScope {
44 /// Gate only the whole-file [`SpaceKind::Unit`] root — the `loc.*`
45 /// size family, whose limit is a per-file ceiling.
46 File,
47 /// Gate only individual function spaces ([`SpaceKind::Function`] —
48 /// free functions, methods, closures). The per-function complexity
49 /// metrics (cognitive, cyclomatic, abc, mi.*) and the subtree sums
50 /// that describe one function (halstead.*, nargs, nexits, tokens)
51 /// live here.
52 ///
53 /// Whether "one function" includes its nested closures is per
54 /// metric, not per scope. `halstead.*`, `nexits` and `tokens` read
55 /// subtree sums, because a closure's tokens and exits really are
56 /// part of the enclosing body a reader must follow. `nargs` reads
57 /// the space's own parameters instead (#1196): a closure that opens
58 /// its own space is gated on its own row, and summing its arguments
59 /// into the enclosing signature made the offender's number describe
60 /// something its remediation could not change.
61 Function,
62 /// Gate only container spaces that own methods (class / struct /
63 /// trait / impl / namespace / interface) — the object-oriented size
64 /// metrics `nom`, `wmc`, `npm`, `npa`.
65 Container,
66}
67
68impl MetricScope {
69 /// Whether a threshold with this scope is evaluated against `kind`.
70 ///
71 /// The single source of truth for the kind-filtering both the CLI
72 /// gate and the Python binding apply, so the two cannot drift on
73 /// which space kinds a metric gates.
74 #[must_use]
75 pub fn admits(self, kind: SpaceKind) -> bool {
76 match self {
77 Self::File => matches!(kind, SpaceKind::Unit),
78 Self::Function => matches!(kind, SpaceKind::Function),
79 Self::Container => matches!(
80 kind,
81 SpaceKind::Class
82 | SpaceKind::Struct
83 | SpaceKind::Trait
84 | SpaceKind::Impl
85 | SpaceKind::Namespace
86 | SpaceKind::Interface
87 ),
88 }
89 }
90}
91
92/// Which direction of a metric's value is unhealthy.
93///
94/// Most metrics grow worse as they grow larger; the Maintainability
95/// Index family is the inverse — a *lower* value is worse. Code Climate
96/// uses this to invert the threshold-breach ratio, and the rule
97/// sentences use it to pick "exceeds" vs "falls below" phrasing.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
99pub enum Direction {
100 /// A higher value is worse (cyclomatic, halstead.*, loc.*, …).
101 HigherIsWorse,
102 /// A lower value is worse (the `mi.*` Maintainability Index family).
103 LowerIsWorse,
104}
105
106/// Catalog entry for one offender-emitting sub-metric id.
107///
108/// The `id` is the dotted key the threshold engine emits for an
109/// offender (`halstead.volume`); `family` groups ids under a top-level
110/// metric (`halstead`) and must match a [`MetricFamily::name`].
111///
112/// `#[non_exhaustive]`: these are read-only records the library
113/// constructs (downstream consumers read fields, never build them), so
114/// a new field can be added in a future minor without a SemVer break.
115#[derive(Debug, Clone, Copy)]
116#[non_exhaustive]
117pub struct MetricInfo {
118 /// Dotted offender id, e.g. `"halstead.volume"` or `"cognitive"`.
119 pub id: &'static str,
120 /// Top-level family this id belongs to, e.g. `"halstead"`.
121 pub family: &'static str,
122 /// Long-form sentence for SARIF `rule.shortDescription.text` and
123 /// the Code Climate `description` prefix.
124 pub long_description: &'static str,
125 /// Whether a higher or lower value is the unhealthy direction.
126 pub direction: Direction,
127 /// Whether the metric's JSON headline is an aggregate across
128 /// descendant spaces (a `sum`/`*_sum` field) that does **not** match
129 /// the CLI threshold accessor's per-space scalar at any interior
130 /// space.
131 ///
132 /// `true` for the four metrics whose serialized JSON value diverges
133 /// from the per-space accessor — `cognitive`, `cyclomatic`,
134 /// `cyclomatic.modified`, and `abc` (#441). The aggregate equals the
135 /// per-space scalar only at a leaf space (no descendant
136 /// function/closure spaces); at any interior space — the file-level
137 /// `unit` or a container with descendants — it is larger.
138 ///
139 /// This flag describes the `sum`/`*_sum` *aggregate* field, which
140 /// still diverges. As of #958 the wire shape **also** serializes each
141 /// of these four metrics' per-space own value (`cyclomatic.value`,
142 /// `cyclomatic.modified.value`, `cognitive.value`, `abc.value`), so a
143 /// JSON-walking front-end no longer needs this flag to stay correct:
144 /// it reads the own value directly and emits at every space exactly
145 /// like the CLI. The Python `to_sarif` binding was switched to that
146 /// path in #958; before it, the binding emitted these only at leaf
147 /// spaces to avoid subtree-wide values masquerading as per-space
148 /// findings the CLI never produces (#855). The flag name retains its
149 /// original unit-only framing.
150 ///
151 /// The flag is **not** derivable from the JSON path string: `nexits`
152 /// also serialises a `sum` field, but its CLI accessor (`nexits_sum()`)
153 /// reads that same aggregate, so it does not diverge and is `false`.
154 /// The divergence is between the JSON field and the CLI accessor,
155 /// which only this registry now records once for both front-ends to
156 /// share (#442).
157 pub skip_at_unit: bool,
158 /// The space kind this metric's threshold gates (issue #969). Both
159 /// the CLI threshold engine and the Python `to_sarif` binding read
160 /// this to skip spaces a metric does not measure, so a metric's
161 /// file-wide or `impl`-wide aggregate never fires as a per-function
162 /// limit. See [`MetricScope`].
163 pub scope: MetricScope,
164}
165
166/// A `bca list-metrics` row: the bare name printed in `names` mode and
167/// the one-line summary printed in `descriptions` mode.
168///
169/// `#[non_exhaustive]` for the same forward-compat reason as
170/// [`MetricInfo`].
171#[derive(Debug, Clone, Copy)]
172#[non_exhaustive]
173pub struct MetricRow {
174 /// Bare name printed one-per-line by `list-metrics`, e.g.
175 /// `"halstead"` or `"sloc"`. Downstream tooling (`bca diff`, which
176 /// buckets per-file metric deltas by these names) relies on them, so
177 /// they are an external contract.
178 pub name: &'static str,
179 /// One-line description printed in `list-metrics descriptions` mode.
180 pub summary: &'static str,
181}
182
183/// A top-level metric family as surfaced by `bca list-metrics`.
184///
185/// Most families render as a single [`MetricRow`] whose name equals
186/// [`name`](Self::name). `loc` is the exception: it renders one row per
187/// sub-measurement (`sloc`, `ploc`, …) because those bare names are an
188/// external grep contract.
189///
190/// `#[non_exhaustive]` for the same forward-compat reason as
191/// [`MetricInfo`].
192#[derive(Debug, Clone, Copy)]
193#[non_exhaustive]
194pub struct MetricFamily {
195 /// Family key, e.g. `"halstead"`, `"loc"`. Matches
196 /// [`MetricInfo::family`].
197 pub name: &'static str,
198 /// `list-metrics` rows for this family, in display order.
199 pub rows: &'static [MetricRow],
200}
201
202/// Canonical offender sub-metric catalog. Long-form sentences and the
203/// `mi.*` lower-is-worse direction moved here verbatim from the former
204/// `output::rule_descriptions` table.
205///
206/// `#[rustfmt::skip]`: the one-row-per-entry layout keeps the table
207/// scannable; rustfmt would otherwise wrap each struct over many lines.
208#[rustfmt::skip]
209pub const METRICS: &[MetricInfo] = &[
210 MetricInfo { id: "cognitive", family: "cognitive", long_description: "Cognitive Complexity exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: true, scope: MetricScope::Function },
211 MetricInfo { id: "cyclomatic", family: "cyclomatic", long_description: "Cyclomatic Complexity exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: true, scope: MetricScope::Function },
212 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 },
213 MetricInfo { id: "halstead.volume", family: "halstead", long_description: "Halstead volume exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function },
214 MetricInfo { id: "halstead.difficulty", family: "halstead", long_description: "Halstead difficulty exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function },
215 MetricInfo { id: "halstead.effort", family: "halstead", long_description: "Halstead effort exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function },
216 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 },
217 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 },
218 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 },
219 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 },
220 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 },
221 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 },
222 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 },
223 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 },
224 MetricInfo { id: "tokens", family: "tokens", long_description: "Number of tokens exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function },
225 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 },
226 MetricInfo { id: "nargs", family: "nargs", long_description: "Number of function arguments exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function },
227 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 },
228 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 },
229 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 },
230 MetricInfo { id: "abc", family: "abc", long_description: "ABC magnitude exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: true, scope: MetricScope::Function },
231 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 },
232 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 },
233 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 },
234];
235
236/// Canonical `bca list-metrics` view. Family summaries moved here
237/// verbatim from the CLI's former hand-maintained catalog. Declaration
238/// order is the `list-metrics` print order.
239///
240/// Only `loc` expands to multiple rows; every other family is a single
241/// row whose name equals the family name.
242pub const FAMILIES: &[MetricFamily] = &[
243 MetricFamily {
244 name: "cognitive",
245 rows: &[MetricRow {
246 name: "cognitive",
247 summary: "Cognitive Complexity: how difficult code is to understand.",
248 }],
249 },
250 MetricFamily {
251 name: "cyclomatic",
252 rows: &[MetricRow {
253 name: "cyclomatic",
254 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.",
255 }],
256 },
257 MetricFamily {
258 name: "halstead",
259 rows: &[MetricRow {
260 name: "halstead",
261 summary: "Halstead suite: vocabulary, length, volume, difficulty, effort, time, bugs.",
262 }],
263 },
264 MetricFamily {
265 name: "loc",
266 rows: &[
267 MetricRow {
268 name: "sloc",
269 summary: "Source lines of code: total lines in a source file.",
270 },
271 MetricRow {
272 name: "ploc",
273 summary: "Physical lines of code: instruction lines.",
274 },
275 MetricRow {
276 name: "lloc",
277 summary: "Logical lines of code: statement count.",
278 },
279 MetricRow {
280 name: "cloc",
281 summary: "Comment lines of code.",
282 },
283 MetricRow {
284 name: "blank",
285 summary: "Blank lines.",
286 },
287 ],
288 },
289 MetricFamily {
290 name: "nom",
291 rows: &[MetricRow {
292 name: "nom",
293 summary: "Number of methods and closures.",
294 }],
295 },
296 MetricFamily {
297 name: "tokens",
298 rows: &[MetricRow {
299 name: "tokens",
300 summary: "Per-function token count: AST leaves excluding comments.",
301 }],
302 },
303 MetricFamily {
304 name: "nexits",
305 rows: &[MetricRow {
306 name: "nexits",
307 summary: "Number of exit points from a function or method.",
308 }],
309 },
310 MetricFamily {
311 name: "nargs",
312 rows: &[MetricRow {
313 name: "nargs",
314 summary: "Number of arguments to a function or method.",
315 }],
316 },
317 MetricFamily {
318 name: "mi",
319 rows: &[MetricRow {
320 name: "mi",
321 summary: "Maintainability Index suite.",
322 }],
323 },
324 MetricFamily {
325 name: "abc",
326 rows: &[MetricRow {
327 name: "abc",
328 summary: "ABC: assignments, branches, and conditions.",
329 }],
330 },
331 MetricFamily {
332 name: "wmc",
333 rows: &[MetricRow {
334 name: "wmc",
335 summary: "Weighted Methods per Class.",
336 }],
337 },
338 MetricFamily {
339 name: "npm",
340 rows: &[MetricRow {
341 name: "npm",
342 summary: "Number of public methods of a class.",
343 }],
344 },
345 MetricFamily {
346 name: "npa",
347 rows: &[MetricRow {
348 name: "npa",
349 summary: "Number of public attributes of a class.",
350 }],
351 },
352];
353
354/// Catalog entry for a known offender id, or `None`. Callers pick their
355/// own fallback for unknown ids (SARIF emits the raw id; Code Climate
356/// falls through to its default message).
357///
358/// Public so out-of-crate consumers (the CLI threshold engine) can read
359/// a metric's [`Direction`] — the `mi.*` family is lower-is-worse, so
360/// the gate and the offender wording must consult it rather than
361/// assuming a higher value is always the violation (#698).
362#[must_use]
363pub fn lookup(id: &str) -> Option<&'static MetricInfo> {
364 METRICS.iter().find(|m| m.id == id)
365}
366
367/// Whether a lower value of the metric `id` is the unhealthy direction
368/// (the `mi.*` Maintainability Index family). The threshold gate, the
369/// Code Climate severity-ratio inversion, and the SARIF/offender wording
370/// all consult this so they never drift from one another. An id the
371/// catalog does not know defaults to higher-is-worse — the same fallback
372/// every offender formatter already uses (#698).
373#[must_use]
374pub fn lower_is_worse(id: &str) -> bool {
375 lookup(id).is_some_and(|m| matches!(m.direction, Direction::LowerIsWorse))
376}
377
378/// The [`MetricScope`] of metric `id` — the space kind its threshold
379/// gates (issue #969). `None` for an id the catalog does not know; both
380/// front-ends treat an unknown id as a usage error before reaching here,
381/// so the `None` arm is only a defensive fallback.
382#[must_use]
383pub fn scope(id: &str) -> Option<MetricScope> {
384 lookup(id).map(|m| m.scope)
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390 use std::collections::HashSet;
391
392 #[test]
393 fn metric_ids_are_unique() {
394 let mut seen = HashSet::new();
395 for m in METRICS {
396 assert!(seen.insert(m.id), "duplicate metric id {:?}", m.id);
397 }
398 }
399
400 #[test]
401 fn family_names_are_unique() {
402 let mut seen = HashSet::new();
403 for f in FAMILIES {
404 assert!(seen.insert(f.name), "duplicate family name {:?}", f.name);
405 }
406 }
407
408 #[test]
409 fn every_metric_family_is_declared() {
410 let families: HashSet<&str> = FAMILIES.iter().map(|f| f.name).collect();
411 for m in METRICS {
412 assert!(
413 families.contains(m.family),
414 "metric {:?} references undeclared family {:?}",
415 m.id,
416 m.family,
417 );
418 }
419 }
420
421 #[test]
422 fn every_family_has_a_metric() {
423 let metric_families: HashSet<&str> = METRICS.iter().map(|m| m.family).collect();
424 for f in FAMILIES {
425 assert!(
426 metric_families.contains(f.name),
427 "family {:?} has no METRICS entry",
428 f.name,
429 );
430 }
431 }
432
433 #[test]
434 fn lookup_round_trips_and_rejects_unknown() {
435 for m in METRICS {
436 assert_eq!(lookup(m.id).map(|i| i.id), Some(m.id));
437 }
438 assert!(lookup("not.a.metric").is_none());
439 }
440
441 /// `mi.*` is the only lower-is-worse family. This pins the data that
442 /// replaced the former `is_lower_is_worse` prefix predicate; if the
443 /// `Direction` of an `mi.*` row is flipped (or a non-`mi` row is
444 /// marked `LowerIsWorse`), Code Climate's breach-ratio inversion
445 /// silently flips with it.
446 #[test]
447 fn lower_is_worse_iff_mi_family() {
448 for m in METRICS {
449 let expect_lower = m.family == "mi";
450 assert_eq!(
451 matches!(m.direction, Direction::LowerIsWorse),
452 expect_lower,
453 "metric {:?} has the wrong direction",
454 m.id,
455 );
456 }
457 }
458
459 #[test]
460 fn lower_is_worse_helper_matches_catalog_and_defaults_false() {
461 assert!(lower_is_worse("mi.original"), "mi.* is lower-is-worse");
462 assert!(
463 !lower_is_worse("cyclomatic"),
464 "cyclomatic is higher-is-worse"
465 );
466 // An id the catalog does not know defaults to higher-is-worse, so
467 // the shared gate never flags an unknown metric on the wrong side.
468 assert!(!lower_is_worse("not_a_metric"));
469 }
470
471 /// `mi.*` sentences phrase the breach as "falls below"; every other
472 /// metric phrases it as "exceeds"/"exceed". This pins the wording to
473 /// the direction so a copy-paste sentence with the wrong verb is
474 /// caught.
475 #[test]
476 fn sentence_phrasing_matches_direction() {
477 for m in METRICS {
478 match m.direction {
479 Direction::LowerIsWorse => assert!(
480 m.long_description.contains("falls below"),
481 "{:?} should use `falls below`: {:?}",
482 m.id,
483 m.long_description,
484 ),
485 Direction::HigherIsWorse => assert!(
486 m.long_description.contains("exceed"),
487 "{:?} should use `exceed(s)`: {:?}",
488 m.id,
489 m.long_description,
490 ),
491 }
492 }
493 }
494
495 /// `skip_at_unit` is `true` for exactly the four metrics whose
496 /// serialized JSON headline at the file-level `unit` space is an
497 /// aggregate over descendant spaces that does not match the CLI
498 /// threshold accessor's per-space scalar (#441). The Python
499 /// `to_sarif` binding mirrors this registry; a cross-crate test in
500 /// `big-code-analysis-py/src/sarif.rs` pins its `METRIC_FIELDS`
501 /// table's flags to these values, so this set is the single source
502 /// of truth both front-ends derive from (#442).
503 ///
504 /// The property is deliberately enumerated rather than derived from
505 /// the id string: `nexits` also serialises a `sum` field but reads
506 /// that same aggregate via its CLI accessor, so it does not diverge.
507 #[test]
508 fn skip_at_unit_is_the_sum_vs_per_space_divergence_set() {
509 let mut skip: Vec<&str> = METRICS
510 .iter()
511 .filter(|m| m.skip_at_unit)
512 .map(|m| m.id)
513 .collect();
514 skip.sort_unstable();
515 assert_eq!(
516 skip,
517 ["abc", "cognitive", "cyclomatic", "cyclomatic.modified"],
518 "skip_at_unit set drifted from the JSON-aggregate-vs-CLI-accessor \
519 property; review against the CLI EXTRACTORS accessors before editing",
520 );
521 }
522
523 /// The per-metric [`MetricScope`] partition (#969): `loc.*` gates the
524 /// file root, the OO size metrics gate containers, everything else
525 /// gates leaf functions. Enumerated so a new metric must be placed
526 /// deliberately rather than defaulting silently — both the CLI gate
527 /// and the Python binding derive their kind-filtering from this.
528 #[test]
529 fn scope_partitions_metrics_by_measured_kind() {
530 let by_scope = |want: MetricScope| {
531 let mut ids: Vec<&str> = METRICS
532 .iter()
533 .filter(|m| m.scope == want)
534 .map(|m| m.id)
535 .collect();
536 ids.sort_unstable();
537 ids
538 };
539 assert_eq!(
540 by_scope(MetricScope::File),
541 ["loc.blank", "loc.cloc", "loc.lloc", "loc.ploc", "loc.sloc"],
542 "only the loc.* size family is File-scoped",
543 );
544 assert_eq!(
545 by_scope(MetricScope::Container),
546 ["nom", "npa", "npm", "wmc"],
547 "only the OO size metrics are Container-scoped",
548 );
549 // Everything else is per-function; spot-check the representatives
550 // and confirm the partition is total (no metric left unscoped).
551 for id in [
552 "cognitive",
553 "cyclomatic",
554 "halstead.effort",
555 "nargs",
556 "nexits",
557 "abc",
558 "mi.original",
559 ] {
560 assert_eq!(
561 scope(id),
562 Some(MetricScope::Function),
563 "{id} should be Function-scoped"
564 );
565 }
566 let counted = by_scope(MetricScope::File).len()
567 + by_scope(MetricScope::Function).len()
568 + by_scope(MetricScope::Container).len();
569 assert_eq!(
570 counted,
571 METRICS.len(),
572 "every metric must have exactly one scope"
573 );
574 }
575
576 /// [`MetricScope::admits`] gates exactly the intended kinds: File only
577 /// the `Unit` root, Function only `Function`, Container the
578 /// method-owning kinds — and nothing admits `Unknown`.
579 #[test]
580 fn scope_admits_only_its_kinds() {
581 assert!(MetricScope::File.admits(SpaceKind::Unit));
582 assert!(!MetricScope::File.admits(SpaceKind::Function));
583 assert!(!MetricScope::File.admits(SpaceKind::Class));
584
585 assert!(MetricScope::Function.admits(SpaceKind::Function));
586 assert!(!MetricScope::Function.admits(SpaceKind::Unit));
587 assert!(!MetricScope::Function.admits(SpaceKind::Impl));
588
589 for kind in [
590 SpaceKind::Class,
591 SpaceKind::Struct,
592 SpaceKind::Trait,
593 SpaceKind::Impl,
594 SpaceKind::Namespace,
595 SpaceKind::Interface,
596 ] {
597 assert!(
598 MetricScope::Container.admits(kind),
599 "{kind:?} is a container"
600 );
601 }
602 assert!(!MetricScope::Container.admits(SpaceKind::Unit));
603 assert!(!MetricScope::Container.admits(SpaceKind::Function));
604
605 for scope in [
606 MetricScope::File,
607 MetricScope::Function,
608 MetricScope::Container,
609 ] {
610 assert!(
611 !scope.admits(SpaceKind::Unknown),
612 "{scope:?} must not admit Unknown"
613 );
614 }
615 }
616}