dry4rust/threshold.rs
1// Copyright (c) 2026 Matjaz Domen Pecan
2// Copyright 2026 Umberto Gotti <umberto.gotti@umbertogotti.dev>
3// Licensed under the MIT License
4// SPDX-License-Identifier: MIT
5
6use crate::error::Error;
7use crate::error::Result;
8
9/// A proportion between none and all, held as a fraction.
10///
11/// Every threshold this tool takes is one of these: a similarity score to
12/// clear, or a share of duplicated lines to stay under. They differ only in
13/// how they are written -- `0.85` in one place, `85.0` in another -- so they
14/// are one type with two constructors rather than two types.
15///
16/// The invariant is the point. A similarity threshold of `5` makes the size
17/// pre-filter reject every pair, and the report then says "no near
18/// duplicates" -- a silence indistinguishable from a clean codebase. Nothing
19/// downstream can tell that apart, so the value never gets that far.
20#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
21pub struct Threshold(f64);
22
23impl Threshold {
24 /// The similarity threshold used when nothing sets one.
25 pub const DEFAULT_SIMILARITY: Self = Self(0.9);
26
27 /// A threshold written as a fraction, as `similarity_threshold` is.
28 ///
29 /// # Errors
30 ///
31 /// Returns [`Error::InvalidConfig`] naming `field` when `value` is
32 /// outside `0.0..=1.0`, which `NaN` also is.
33 pub fn fraction(field: &'static str, value: f64) -> Result<Self> {
34 Self::share_of(field, value, 1.0, "a fraction between 0.0 and 1.0")
35 }
36
37 /// A threshold written as a percentage, as the `check` ceilings are.
38 ///
39 /// # Errors
40 ///
41 /// Returns [`Error::InvalidConfig`] naming `field` when `value` is
42 /// outside `0.0..=100.0`, which `NaN` also is.
43 pub fn percent(field: &'static str, value: f64) -> Result<Self> {
44 Self::share_of(field, value, 100.0, "a percentage between 0.0 and 100.0")
45 }
46
47 /// `value` as a share of `whole`, refused unless it lies in `0.0..=whole`.
48 ///
49 /// Dividing by a `whole` of one is exact in floating point, so a fraction
50 /// is stored as the very value it was written as.
51 fn share_of(
52 field: &'static str,
53 value: f64,
54 whole: f64,
55 expected: &'static str,
56 ) -> Result<Self> {
57 if (0.0..=whole).contains(&value) {
58 Ok(Self(value / whole))
59 } else {
60 Err(Error::InvalidConfig {
61 field,
62 value: value.to_string(),
63 expected,
64 })
65 }
66 }
67
68 /// The threshold as a fraction of one.
69 #[must_use]
70 pub const fn as_fraction(self) -> f64 {
71 self.0
72 }
73
74 /// The threshold as a share of a hundred.
75 #[must_use]
76 pub fn as_percent(self) -> f64 {
77 self.0 * 100.0
78 }
79}