big_code_analysis/metric_set.rs
1//! Per-metric selection: the [`Metric`] enum and the
2//! [`MetricSet`] bitfield it gates.
3//!
4//! Used by [`MetricsOptions::with_only`](crate::MetricsOptions::with_only)
5//! to restrict which metrics are computed during a walk, and by
6//! [`CodeMetrics`](crate::CodeMetrics)'s `Serialize` impl to elide
7//! fields the caller did not select.
8
9use std::fmt;
10use std::str::FromStr;
11
12/// One metric computed by the analysis walker.
13///
14/// Pass a slice of these to
15/// [`MetricsOptions::with_only`](crate::MetricsOptions::with_only) to
16/// restrict computation to the listed metrics.
17///
18/// `#[non_exhaustive]` so future metrics can land additively. Use
19/// `match` against the existing variants and either a wildcard arm or
20/// the `m if !MetricSet::all().contains(m)` guard to stay
21/// forwards-compatible.
22#[non_exhaustive]
23#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
24pub enum Metric {
25 /// Cognitive complexity ([`crate::cognitive::Stats`]).
26 Cognitive,
27 /// Cyclomatic complexity ([`crate::cyclomatic::Stats`]).
28 Cyclomatic,
29 /// Halstead ([`crate::halstead::Stats`]).
30 Halstead,
31 /// LoC family ([`crate::loc::Stats`]).
32 Loc,
33 /// Number of methods ([`crate::nom::Stats`]).
34 Nom,
35 /// Token counts ([`crate::tokens::Stats`]).
36 Tokens,
37 /// Number of arguments ([`crate::nargs::Stats`]).
38 NArgs,
39 /// Exit-point count ([`crate::exit::Stats`]).
40 Exit,
41 /// ABC ([`crate::abc::Stats`]).
42 Abc,
43 /// Number of public methods ([`crate::npm::Stats`]).
44 Npm,
45 /// Number of public attributes ([`crate::npa::Stats`]).
46 Npa,
47 /// Maintainability index ([`crate::mi::Stats`]). Derived metric:
48 /// selecting only `Mi` via
49 /// [`MetricsOptions::with_only`](crate::MetricsOptions::with_only)
50 /// also pulls in [`Metric::Loc`], [`Metric::Cyclomatic`], and
51 /// [`Metric::Halstead`].
52 Mi,
53 /// Weighted methods per class ([`crate::wmc::Stats`]). Derived
54 /// metric: selecting `Wmc` also pulls in [`Metric::Cyclomatic`]
55 /// and [`Metric::Nom`].
56 Wmc,
57}
58
59impl Metric {
60 // Bit position used inside [`MetricSet`]. The ordering is
61 // intentionally arbitrary — the only contract is that each
62 // variant maps to a distinct bit.
63 #[inline]
64 const fn bit(self) -> u16 {
65 1 << (self as u32)
66 }
67
68 /// Returns the slice of metrics this metric depends on.
69 ///
70 /// Derived metrics (`Mi`, `Wmc`) consume the outputs of other
71 /// metrics during the finalize step; selecting one without its
72 /// dependencies would leave the dependency's `Stats` at default
73 /// (zero) values and silently corrupt the derived value. Callers
74 /// typically reach this through
75 /// [`MetricsOptions::with_only`](crate::MetricsOptions::with_only),
76 /// which auto-resolves the closure transparently.
77 #[must_use]
78 pub const fn dependencies(self) -> &'static [Metric] {
79 match self {
80 // Mi = function(Loc, Cyclomatic, Halstead). All three must
81 // be computed for the MI formula to be meaningful.
82 Self::Mi => &[Self::Loc, Self::Cyclomatic, Self::Halstead],
83 // Wmc aggregates per-method cyclomatic complexity and
84 // needs Nom to count those methods.
85 Self::Wmc => &[Self::Cyclomatic, Self::Nom],
86 _ => &[],
87 }
88 }
89
90 /// Canonical user-facing name for each metric — the single
91 /// source of truth shared by the Python bindings'
92 /// `bca.METRIC_NAMES` constant, the `unknown metric: <bad>;
93 /// valid: …` error message, and any downstream Rust consumer
94 /// that parses user input into a [`MetricSet`].
95 ///
96 /// Each entry round-trips through [`Metric::from_str`]. The table uses the
97 /// JSON-output-key spelling for [`Metric::Exit`] (`"nexits"`,
98 /// matching the `CodeMetrics::Serialize` impl in
99 /// `src/spaces.rs`) rather than the [`fmt::Display`] spelling
100 /// (`"exit"`); both parse to [`Metric::Exit`] via the alias
101 /// arm in `FromStr`, but the canonical spelling exposed here
102 /// is the JSON one so callers see the same name in
103 /// `Metric::NAMES`, in the output dict, and in error
104 /// messages.
105 ///
106 /// Alphabetised. The drift between this table and the
107 /// `FromStr` arms (or the `Metric` enum itself) is guarded by
108 /// `names_table_parses_to_every_variant` and
109 /// `names_table_is_alphabetised` in the test module below.
110 pub const NAMES: &'static [&'static str] = &[
111 "abc",
112 "cognitive",
113 "cyclomatic",
114 "halstead",
115 "loc",
116 "mi",
117 "nargs",
118 "nexits",
119 "nom",
120 "npa",
121 "npm",
122 "tokens",
123 "wmc",
124 ];
125}
126
127impl fmt::Display for Metric {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 let s = match self {
130 Self::Cognitive => "cognitive",
131 Self::Cyclomatic => "cyclomatic",
132 Self::Halstead => "halstead",
133 Self::Loc => "loc",
134 Self::Nom => "nom",
135 Self::Tokens => "tokens",
136 Self::NArgs => "nargs",
137 Self::Exit => "exit",
138 Self::Abc => "abc",
139 Self::Npm => "npm",
140 Self::Npa => "npa",
141 Self::Mi => "mi",
142 Self::Wmc => "wmc",
143 };
144 f.write_str(s)
145 }
146}
147
148/// Error returned by [`Metric::from_str`] when the input
149/// is not a recognised metric name.
150///
151/// Holds the offending input verbatim. Downstream consumers that own
152/// the canonical name table (e.g. the `bca` Python bindings'
153/// `METRIC_NAMES` constant) typically compose this with a
154/// `valid: <list>` suffix from their own source of truth; this type
155/// deliberately stays out of that policy and only carries the
156/// rejected input so the wrapper layer can format the user-facing
157/// message however it wants.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct ParseMetricError(String);
160
161impl fmt::Display for ParseMetricError {
162 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163 write!(f, "unknown metric: {}", self.0)
164 }
165}
166
167impl std::error::Error for ParseMetricError {}
168
169impl FromStr for Metric {
170 type Err = ParseMetricError;
171
172 /// Parse a [`Metric`] from its [`fmt::Display`] spelling.
173 ///
174 /// Strict lowercase: `"Loc"` is rejected. The single alias is
175 /// `"nexits"`, which parses to [`Metric::Exit`] — this matches
176 /// the JSON output key the metric's `Stats` serialises under,
177 /// so downstream consumers can use either the enum-Display
178 /// spelling or the JSON-key spelling interchangeably.
179 fn from_str(s: &str) -> Result<Self, Self::Err> {
180 match s {
181 "cognitive" => Ok(Self::Cognitive),
182 "cyclomatic" => Ok(Self::Cyclomatic),
183 "halstead" => Ok(Self::Halstead),
184 "loc" => Ok(Self::Loc),
185 "nom" => Ok(Self::Nom),
186 "tokens" => Ok(Self::Tokens),
187 "nargs" => Ok(Self::NArgs),
188 "exit" | "nexits" => Ok(Self::Exit),
189 "abc" => Ok(Self::Abc),
190 "npm" => Ok(Self::Npm),
191 "npa" => Ok(Self::Npa),
192 "mi" => Ok(Self::Mi),
193 "wmc" => Ok(Self::Wmc),
194 _ => Err(ParseMetricError(s.to_owned())),
195 }
196 }
197}
198
199/// Bitfield of selected metrics.
200///
201/// Stored on [`MetricsOptions`](crate::MetricsOptions) (controls
202/// which metrics the walker computes) and on
203/// [`CodeMetrics`](crate::CodeMetrics) (controls which fields the
204/// `Serialize` impl emits).
205///
206/// `MetricSet::all()` is the default: every metric enabled, matching
207/// the pre-#257 behaviour.
208#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
209pub struct MetricSet(u16);
210
211impl MetricSet {
212 // All-metrics mask: OR together every variant's bit. Kept
213 // explicit (rather than `(1 << N) - 1`) so adding a new variant
214 // requires a deliberate edit here and surfaces in code review.
215 const ALL_BITS: u16 = Metric::Cognitive.bit()
216 | Metric::Cyclomatic.bit()
217 | Metric::Halstead.bit()
218 | Metric::Loc.bit()
219 | Metric::Nom.bit()
220 | Metric::Tokens.bit()
221 | Metric::NArgs.bit()
222 | Metric::Exit.bit()
223 | Metric::Abc.bit()
224 | Metric::Npm.bit()
225 | Metric::Npa.bit()
226 | Metric::Mi.bit()
227 | Metric::Wmc.bit();
228
229 /// Empty set (no metrics selected).
230 #[inline]
231 #[must_use]
232 pub const fn empty() -> Self {
233 Self(0)
234 }
235
236 /// Full set (every metric selected). This is the default for
237 /// [`MetricsOptions`](crate::MetricsOptions), preserving the
238 /// pre-#257 "compute everything" behaviour.
239 #[inline]
240 #[must_use]
241 pub const fn all() -> Self {
242 Self(Self::ALL_BITS)
243 }
244
245 /// Returns `true` if `metric` is in the set.
246 #[inline]
247 #[must_use]
248 pub const fn contains(self, metric: Metric) -> bool {
249 (self.0 & metric.bit()) != 0
250 }
251
252 /// Returns a new set with `metric` inserted.
253 #[inline]
254 #[must_use]
255 pub const fn with(self, metric: Metric) -> Self {
256 Self(self.0 | metric.bit())
257 }
258
259 /// Returns the union of two sets.
260 #[inline]
261 #[must_use]
262 pub const fn union(self, other: Self) -> Self {
263 Self(self.0 | other.0)
264 }
265
266 /// Insert `metric` (in place).
267 #[inline]
268 pub fn insert(&mut self, metric: Metric) {
269 self.0 |= metric.bit();
270 }
271
272 /// Build a `MetricSet` from a slice, auto-adding the transitive
273 /// dependencies of each selected metric.
274 ///
275 /// This is the workhorse behind
276 /// [`MetricsOptions::with_only`](crate::MetricsOptions::with_only):
277 /// the caller-facing builder enforces the full dependency closure
278 /// so a request for `Mi` alone still computes
279 /// `Loc + Cyclomatic + Halstead`. Exposed `pub` because
280 /// downstream consumers (notably the `bca` Python bindings'
281 /// `parse_metric_names` helper) parse user input into a
282 /// `Vec<Metric>` and need the same closure-resolution semantics
283 /// without re-implementing the worklist.
284 ///
285 /// Implementation note: uses a worklist rather than a single pass
286 /// so a future derived metric whose dependency is itself derived
287 /// still resolves the complete closure. The loop terminates
288 /// because each iteration either inserts a new bit or the
289 /// worklist drains; the bitfield is bounded at `Metric` variant
290 /// count.
291 #[must_use]
292 pub fn from_slice_with_deps(metrics: &[Metric]) -> Self {
293 let mut set = Self::empty();
294 let mut worklist: Vec<Metric> = metrics.to_vec();
295 while let Some(m) = worklist.pop() {
296 if set.contains(m) {
297 continue;
298 }
299 set.insert(m);
300 for &dep in m.dependencies() {
301 if !set.contains(dep) {
302 worklist.push(dep);
303 }
304 }
305 }
306 set
307 }
308}
309
310impl Default for MetricSet {
311 /// Default = every metric selected, matching the pre-#257
312 /// behaviour of [`MetricsOptions::default`](crate::MetricsOptions::default).
313 #[inline]
314 fn default() -> Self {
315 Self::all()
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn empty_contains_nothing() {
325 let set = MetricSet::empty();
326 assert!(!set.contains(Metric::Loc));
327 assert!(!set.contains(Metric::Halstead));
328 assert!(!set.contains(Metric::Mi));
329 }
330
331 #[test]
332 fn all_contains_every_variant() {
333 let set = MetricSet::all();
334 for m in [
335 Metric::Cognitive,
336 Metric::Cyclomatic,
337 Metric::Halstead,
338 Metric::Loc,
339 Metric::Nom,
340 Metric::Tokens,
341 Metric::NArgs,
342 Metric::Exit,
343 Metric::Abc,
344 Metric::Npm,
345 Metric::Npa,
346 Metric::Mi,
347 Metric::Wmc,
348 ] {
349 assert!(set.contains(m), "MetricSet::all() must contain {m}");
350 }
351 }
352
353 #[test]
354 fn with_dependencies_pulls_in_mi_inputs() {
355 let set = MetricSet::from_slice_with_deps(&[Metric::Mi]);
356 assert!(set.contains(Metric::Mi));
357 assert!(set.contains(Metric::Loc), "Mi depends on Loc");
358 assert!(set.contains(Metric::Cyclomatic), "Mi depends on Cyclomatic");
359 assert!(set.contains(Metric::Halstead), "Mi depends on Halstead");
360 // Unrelated metrics stay out.
361 assert!(!set.contains(Metric::Abc));
362 assert!(!set.contains(Metric::Tokens));
363 }
364
365 #[test]
366 fn with_dependencies_pulls_in_wmc_inputs() {
367 let set = MetricSet::from_slice_with_deps(&[Metric::Wmc]);
368 assert!(set.contains(Metric::Wmc));
369 assert!(
370 set.contains(Metric::Cyclomatic),
371 "Wmc depends on Cyclomatic"
372 );
373 assert!(set.contains(Metric::Nom), "Wmc depends on Nom");
374 }
375
376 // Listing a metric that is already in another entry's closure
377 // is a no-op and does not corrupt or duplicate state. Today's
378 // dependency graph is flat (Mi/Wmc both depend only on leaf
379 // metrics), so this test cannot exercise the worklist's
380 // transitive resolution — a single-pass implementation that
381 // pulls in only direct dependencies would also pass. When a
382 // derived-of-derived metric lands, replace this with a test
383 // that actually exercises the multi-hop closure (e.g. by
384 // feeding an entry whose dependency itself has a non-empty
385 // `dependencies()` list).
386 #[test]
387 fn closure_is_idempotent_for_mixed_input() {
388 let a = MetricSet::from_slice_with_deps(&[Metric::Mi, Metric::Loc]);
389 let b = MetricSet::from_slice_with_deps(&[Metric::Mi]);
390 assert_eq!(a, b);
391 }
392
393 // The closure must terminate even when the input contains
394 // duplicates; the worklist algorithm guards against this by
395 // skipping bits already set.
396 #[test]
397 fn closure_handles_duplicate_input() {
398 let set = MetricSet::from_slice_with_deps(&[Metric::Mi, Metric::Mi, Metric::Mi]);
399 assert_eq!(set, MetricSet::from_slice_with_deps(&[Metric::Mi]));
400 }
401
402 #[test]
403 fn empty_slice_yields_empty_set() {
404 assert_eq!(MetricSet::from_slice_with_deps(&[]), MetricSet::empty());
405 }
406
407 /// Every `Metric` variant. Tests that need to walk the enum
408 /// exhaustively reach for this constant so a newly-added variant
409 /// surfaces as a compile error here (the wildcard in the
410 /// initialiser would not catch a missed `match` arm in
411 /// [`fmt::Display`] or [`std::str::FromStr`]).
412 const ALL_VARIANTS: &[Metric] = &[
413 Metric::Cognitive,
414 Metric::Cyclomatic,
415 Metric::Halstead,
416 Metric::Loc,
417 Metric::Nom,
418 Metric::Tokens,
419 Metric::NArgs,
420 Metric::Exit,
421 Metric::Abc,
422 Metric::Npm,
423 Metric::Npa,
424 Metric::Mi,
425 Metric::Wmc,
426 ];
427
428 #[test]
429 fn from_str_round_trips_every_variant_display_name() {
430 // Reverting any single arm in `impl FromStr for Metric`
431 // makes this fail on exactly that variant — the test is
432 // load-bearing per `.claude/rules/testing.md`.
433 for &m in ALL_VARIANTS {
434 let parsed: Metric = m
435 .to_string()
436 .parse()
437 .unwrap_or_else(|e| panic!("Display->FromStr round-trip failed for {m}: {e}"));
438 assert_eq!(parsed, m, "round-trip mismatch for {m}");
439 }
440 }
441
442 #[test]
443 fn from_str_accepts_nexits_alias_for_exit() {
444 // `Metric::Exit` serialises as JSON key "nexits"; we accept
445 // both spellings so consumers can name the metric by either
446 // its enum-Display spelling or its JSON output key.
447 assert_eq!("exit".parse::<Metric>().unwrap(), Metric::Exit);
448 assert_eq!("nexits".parse::<Metric>().unwrap(), Metric::Exit);
449 }
450
451 #[test]
452 fn from_str_rejects_uppercase() {
453 let err = "Loc".parse::<Metric>().unwrap_err();
454 assert_eq!(err.to_string(), "unknown metric: Loc");
455 }
456
457 // Drift guard: every entry in `Metric::NAMES` must parse via
458 // `FromStr`, and every variant must have at least one entry
459 // in the table that parses to it (the `"exit"`/`"nexits"`
460 // alias means `Exit` is reached via the canonical `"nexits"`
461 // spelling, not via the Display arm). Adding a `Metric`
462 // variant without a `NAMES` entry — or vice versa — fails
463 // here before any pytest run.
464 #[test]
465 fn names_table_parses_to_every_variant() {
466 use std::collections::HashSet;
467 let mut seen: HashSet<Metric> = HashSet::new();
468 for name in Metric::NAMES {
469 let parsed = name.parse::<Metric>().unwrap_or_else(|_| {
470 panic!("Metric::NAMES contains {name:?} but FromStr rejects it")
471 });
472 seen.insert(parsed);
473 }
474 for &m in ALL_VARIANTS {
475 assert!(
476 seen.contains(&m),
477 "Metric::{m:?} is not represented in Metric::NAMES; \
478 add the canonical spelling to the table",
479 );
480 }
481 }
482
483 // The error-message `valid: <list>` and the public
484 // `bca.METRIC_NAMES` tuple both surface this slice verbatim;
485 // pinning the alphabetised invariant catches accidental
486 // re-orderings on `cargo test`.
487 #[test]
488 fn names_table_is_alphabetised() {
489 let mut sorted: Vec<&str> = Metric::NAMES.to_vec();
490 sorted.sort_unstable();
491 assert_eq!(
492 Metric::NAMES,
493 sorted.as_slice(),
494 "Metric::NAMES must stay alphabetised",
495 );
496 }
497
498 // `MetricsOptions::with_metric_set` consumes its argument
499 // verbatim — no closure resolution. Pinning the contrast with
500 // `with_only` (which DOES resolve deps) catches a future
501 // "helpful" refactor that adds auto-resolution to
502 // `with_metric_set`: such a change would silently fix some
503 // callers but invalidate the public-API contract documented
504 // on the builder, where "this set MUST be closed before it
505 // reaches this builder" is the load-bearing precondition.
506 //
507 // The test lives alongside `MetricSet` rather than in
508 // `spaces.rs` because the contrast is between two `MetricSet`
509 // operations: `from_slice_with_deps` (closure-resolving) vs.
510 // raw construction via `empty().with(...)` (no resolution).
511 #[test]
512 fn with_metric_set_does_not_resolve_dependencies() {
513 // `from_slice_with_deps(&[Mi])` includes Loc, Cyclomatic,
514 // Halstead alongside Mi…
515 let resolved = MetricSet::from_slice_with_deps(&[Metric::Mi]);
516 assert!(resolved.contains(Metric::Mi));
517 assert!(resolved.contains(Metric::Loc));
518 assert!(resolved.contains(Metric::Cyclomatic));
519 assert!(resolved.contains(Metric::Halstead));
520
521 // …whereas `empty().with(Mi)` does NOT auto-resolve, and
522 // the caller-owned closure precondition documented on
523 // `MetricsOptions::with_metric_set` is what guards
524 // against MI being computed against zero-valued inputs.
525 let bare = MetricSet::empty().with(Metric::Mi);
526 assert!(bare.contains(Metric::Mi));
527 assert!(!bare.contains(Metric::Loc), "with(Mi) must NOT pull Loc");
528 assert!(
529 !bare.contains(Metric::Cyclomatic),
530 "with(Mi) must NOT pull Cyclomatic",
531 );
532 assert!(
533 !bare.contains(Metric::Halstead),
534 "with(Mi) must NOT pull Halstead",
535 );
536 }
537
538 #[test]
539 fn from_str_rejects_unknown_name() {
540 let err = "bogus".parse::<Metric>().unwrap_err();
541 assert_eq!(err.to_string(), "unknown metric: bogus");
542 }
543
544 #[test]
545 fn distinct_bits_per_variant() {
546 // Each variant must map to a distinct bit; otherwise the
547 // bitfield silently aliases two metrics and gating one
548 // toggles the other.
549 let mut seen: u16 = 0;
550 for m in [
551 Metric::Cognitive,
552 Metric::Cyclomatic,
553 Metric::Halstead,
554 Metric::Loc,
555 Metric::Nom,
556 Metric::Tokens,
557 Metric::NArgs,
558 Metric::Exit,
559 Metric::Abc,
560 Metric::Npm,
561 Metric::Npa,
562 Metric::Mi,
563 Metric::Wmc,
564 ] {
565 let bit = m.bit();
566 assert_eq!(seen & bit, 0, "duplicate bit for {m}: {bit:#b}");
567 seen |= bit;
568 }
569 assert_eq!(seen, MetricSet::ALL_BITS);
570 }
571}