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