Skip to main content

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}