Skip to main content

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}