Skip to main content

scute_core/
lib.rs

1//! Deterministic fitness checks for software delivery.
2//!
3//! Each check returns `Result<Vec<Evaluation>, ExecutionError>`.
4//! Use [`report::CheckReport`] to summarize results for presentation.
5//!
6//! # Available checks
7//!
8//! - [`code_similarity::check`] — Code duplication detection
9//! - [`commit_message`] — Conventional Commits validation
10//! - [`dependency_freshness`] — Cargo dependency freshness
11
12pub mod code_similarity;
13pub mod commit_message;
14pub mod dependency_freshness;
15pub mod report;
16
17use serde::{Deserialize, Serialize};
18
19/// Whether a check passed, warned, or failed.
20///
21/// Derived by comparing the `observed` value against [`Thresholds`].
22#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
23#[serde(rename_all = "lowercase")]
24pub enum Status {
25    Pass,
26    Warn,
27    Fail,
28}
29
30impl std::fmt::Display for Status {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            Self::Pass => write!(f, "pass"),
34            Self::Warn => write!(f, "warn"),
35            Self::Fail => write!(f, "fail"),
36        }
37    }
38}
39
40/// Warn and fail boundaries for a check.
41///
42/// When both are set, their relative order determines direction:
43/// `warn < fail` means higher is worse (e.g. violation counts),
44/// `warn > fail` means lower is worse (e.g. coverage percentages).
45///
46/// ```
47/// use scute_core::Thresholds;
48///
49/// // "More than 0 violations is a failure"
50/// let violations = Thresholds { warn: None, fail: Some(0) };
51///
52/// // "Coverage below 70% warns, below 50% fails"
53/// let coverage = Thresholds { warn: Some(70), fail: Some(50) };
54/// ```
55#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
56pub struct Thresholds {
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub warn: Option<u64>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub fail: Option<u64>,
61}
62
63/// Result of evaluating a check against a single target.
64///
65/// ```
66/// use scute_core::commit_message;
67/// use scute_core::commit_message::Definition;
68///
69/// let results = commit_message::check("feat: add login", &Definition::default()).unwrap();
70/// let eval = &results[0];
71/// assert_eq!(eval.target, "feat: add login");
72/// assert!(eval.is_pass());
73/// ```
74#[derive(Debug, PartialEq)]
75pub struct Evaluation {
76    pub target: String,
77    pub outcome: Outcome,
78}
79
80impl Evaluation {
81    pub fn completed(
82        target: impl Into<String>,
83        observed: u64,
84        thresholds: Thresholds,
85        evidence: Vec<Evidence>,
86    ) -> Self {
87        Self {
88            target: target.into(),
89            outcome: Outcome::completed(observed, thresholds, evidence),
90        }
91    }
92
93    pub fn errored(target: impl Into<String>, error: ExecutionError) -> Self {
94        Self {
95            target: target.into(),
96            outcome: Outcome::Errored(error),
97        }
98    }
99
100    #[must_use]
101    pub fn is_pass(&self) -> bool {
102        self.has_status(Status::Pass)
103    }
104
105    #[must_use]
106    pub fn is_warn(&self) -> bool {
107        self.has_status(Status::Warn)
108    }
109
110    #[must_use]
111    pub fn is_fail(&self) -> bool {
112        self.has_status(Status::Fail)
113    }
114
115    fn has_status(&self, expected: Status) -> bool {
116        matches!(&self.outcome, Outcome::Completed { status, .. } if *status == expected)
117    }
118
119    #[must_use]
120    pub fn is_error(&self) -> bool {
121        matches!(&self.outcome, Outcome::Errored(_))
122    }
123}
124
125/// What happened when a check ran against a target.
126#[derive(Debug, PartialEq)]
127pub enum Outcome {
128    Completed {
129        status: Status,
130        observed: u64,
131        thresholds: Thresholds,
132        evidence: Vec<Evidence>,
133    },
134    Errored(ExecutionError),
135}
136
137impl Outcome {
138    /// Create a completed outcome, deriving [`Status`] from `observed` and `thresholds`.
139    #[must_use]
140    pub fn completed(observed: u64, thresholds: Thresholds, evidence: Vec<Evidence>) -> Self {
141        Self::Completed {
142            status: derive_status(observed, &thresholds),
143            observed,
144            thresholds,
145            evidence,
146        }
147    }
148}
149
150/// Structured error when a check cannot execute.
151#[derive(Debug, PartialEq, Serialize)]
152pub struct ExecutionError {
153    pub code: String,
154    pub message: String,
155    pub recovery: String,
156}
157
158/// What a check expected to find instead of the violation.
159///
160/// Serializes without a type tag: [`Text`](Expected::Text) becomes a JSON
161/// string, [`List`](Expected::List) becomes a JSON array.
162///
163/// ```
164/// use scute_core::Expected;
165///
166/// let format = Expected::Text("type(scope): description".into());
167/// let types = Expected::List(vec!["feat".into(), "fix".into()]);
168/// ```
169#[derive(Clone, Debug, PartialEq, Serialize)]
170#[serde(untagged)]
171pub enum Expected {
172    Text(String),
173    List(Vec<String>),
174}
175
176/// A single violation found during a check.
177///
178/// `rule` identifies what was violated, `found` shows what triggered it.
179/// `expected` optionally carries what the check expected instead, when
180/// the rule name alone isn't enough to act on.
181///
182/// ```
183/// use scute_core::{Evidence, Expected};
184///
185/// let e = Evidence::with_expected(
186///     "unknown-type",
187///     "banana",
188///     Expected::List(vec!["feat".into(), "fix".into()]),
189/// );
190/// assert_eq!(e.rule.as_deref(), Some("unknown-type"));
191/// assert_eq!(e.found, "banana");
192/// assert!(e.expected.is_some());
193/// ```
194#[derive(Debug, PartialEq, Serialize)]
195pub struct Evidence {
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub rule: Option<String>,
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub location: Option<String>,
200    pub found: String,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub expected: Option<Expected>,
203}
204
205impl Evidence {
206    #[must_use]
207    pub fn new(rule: &str, found: &str) -> Self {
208        Self {
209            rule: Some(rule.into()),
210            location: None,
211            found: found.into(),
212            expected: None,
213        }
214    }
215
216    #[must_use]
217    pub fn with_expected(rule: &str, found: &str, expected: Expected) -> Self {
218        Self {
219            rule: Some(rule.into()),
220            location: None,
221            found: found.into(),
222            expected: Some(expected),
223        }
224    }
225}
226
227pub(crate) fn derive_status(observed: u64, thresholds: &Thresholds) -> Status {
228    let higher_is_worse = match (thresholds.warn, thresholds.fail) {
229        (Some(w), Some(f)) => w < f,
230        _ => true,
231    };
232
233    let exceeds = if higher_is_worse {
234        |observed: u64, threshold: u64| observed > threshold
235    } else {
236        |observed: u64, threshold: u64| observed < threshold
237    };
238
239    if let Some(fail) = thresholds.fail
240        && exceeds(observed, fail)
241    {
242        return Status::Fail;
243    }
244
245    if let Some(warn) = thresholds.warn
246        && exceeds(observed, warn)
247    {
248        return Status::Warn;
249    }
250
251    Status::Pass
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use test_case::test_case;
258
259    // higher-is-worse: warn < fail, or no warn
260    #[test_case(3, None, Some(5), Status::Pass ; "below fail without warn passes")]
261    #[test_case(5, None, Some(5), Status::Pass ; "at fail without warn passes")]
262    #[test_case(10, None, Some(5), Status::Fail ; "above fail without warn fails")]
263    #[test_case(5, Some(5), Some(10), Status::Pass ; "at warn threshold passes")]
264    #[test_case(10, Some(3), Some(10), Status::Warn ; "at fail with warn returns warn")]
265    fn higher_is_worse(observed: u64, warn: Option<u64>, fail: Option<u64>, expected: Status) {
266        assert_eq!(
267            derive_status(observed, &Thresholds { warn, fail }),
268            expected
269        );
270    }
271
272    // lower-is-worse: warn > fail (coverage-style), all share thresholds (70, 50)
273    #[test_case(40, Status::Fail ; "below fail threshold fails")]
274    #[test_case(50, Status::Warn ; "at fail threshold returns warn")]
275    #[test_case(70, Status::Pass ; "at warn threshold passes")]
276    #[test_case(80, Status::Pass ; "above warn threshold passes")]
277    fn lower_is_worse(observed: u64, expected: Status) {
278        let thresholds = Thresholds {
279            warn: Some(70),
280            fail: Some(50),
281        };
282        assert_eq!(derive_status(observed, &thresholds), expected);
283    }
284
285    #[test]
286    fn evaluation_is_pass_for_completed_pass() {
287        let eval = Evaluation::completed(
288            "test",
289            0,
290            Thresholds {
291                warn: None,
292                fail: Some(0),
293            },
294            vec![],
295        );
296
297        assert!(eval.is_pass());
298        assert!(!eval.is_fail());
299        assert!(!eval.is_warn());
300        assert!(!eval.is_error());
301    }
302
303    #[test]
304    fn evaluation_is_fail_for_completed_fail() {
305        let eval = Evaluation::completed(
306            "test",
307            1,
308            Thresholds {
309                warn: None,
310                fail: Some(0),
311            },
312            vec![],
313        );
314
315        assert!(eval.is_fail());
316        assert!(!eval.is_pass());
317    }
318
319    #[test]
320    fn evaluation_is_error_for_errored_outcome() {
321        let eval = Evaluation::errored(
322            "test",
323            ExecutionError {
324                code: "boom".into(),
325                message: "broken".into(),
326                recovery: "fix".into(),
327            },
328        );
329
330        assert!(eval.is_error());
331        assert!(!eval.is_pass());
332        assert!(!eval.is_fail());
333    }
334}