Skip to main content

callisto_model/
severity.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4/// §5.1, §6.1. A changeset's declared severity for one named package, **and** §7.4's internal
5/// cascade outcome for an out-of-range dev-dependency ("spec rewrite only, no version bump").
6/// Only the file-format usage is ever persisted to disk as a changeset.
7///
8/// **Variant order is deliberate, not alphabetical.** The derived `Ord` is the
9/// aggregation-by-max lattice §7.1 relies on: `None < Patch < Minor < Major`.
10#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)]
11#[serde(rename_all = "lowercase")]
12pub enum Severity {
13    None,
14    Patch,
15    Minor,
16    Major,
17}
18
19impl Severity {
20    /// All four variants in ascending order — for exhaustive fixture tables and CLI help
21    /// text, so no second hand-maintained list can drift from the enum.
22    pub const ALL: [Severity; 4] = [Severity::None, Severity::Patch, Severity::Minor, Severity::Major];
23}
24
25/// §6.1: "case-insensitive read, lowercase write." `FromStr` is the read half, `Display` the
26/// write half. The asymmetry is the spec, not a bug to unify.
27impl std::str::FromStr for Severity {
28    type Err = SeverityParseError;
29    fn from_str(s: &str) -> Result<Self, Self::Err> {
30        match s.trim().to_ascii_lowercase().as_str() {
31            "major" => Ok(Severity::Major),
32            "minor" => Ok(Severity::Minor),
33            "patch" => Ok(Severity::Patch),
34            "none" => Ok(Severity::None),
35            _ => Err(SeverityParseError { found: s.to_string() }),
36        }
37    }
38}
39
40impl std::fmt::Display for Severity {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.write_str(match self {
43            Severity::Major => "major",
44            Severity::Minor => "minor",
45            Severity::Patch => "patch",
46            Severity::None => "none",
47        })
48    }
49}
50
51/// The token read where `major | minor | patch | none` (any case) was expected.
52#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
53#[error("invalid severity {found:?}: expected one of \"major\", \"minor\", \"patch\", \"none\" (case-insensitive)")]
54pub struct SeverityParseError {
55    pub found: String,
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use std::str::FromStr;
62
63    #[test]
64    fn parses_lowercase_variants() {
65        assert_eq!(Severity::from_str("major").unwrap(), Severity::Major);
66        assert_eq!(Severity::from_str("minor").unwrap(), Severity::Minor);
67        assert_eq!(Severity::from_str("patch").unwrap(), Severity::Patch);
68        assert_eq!(Severity::from_str("none").unwrap(), Severity::None);
69    }
70
71    #[test]
72    fn parses_case_insensitively() {
73        assert_eq!(Severity::from_str("MAJOR").unwrap(), Severity::Major);
74        assert_eq!(Severity::from_str("Minor").unwrap(), Severity::Minor);
75    }
76
77    #[test]
78    fn rejects_unknown_token() {
79        let err = Severity::from_str("critical").unwrap_err();
80        assert_eq!(err.found, "critical");
81    }
82
83    #[test]
84    fn displays_lowercase_regardless_of_input_case() {
85        assert_eq!(Severity::Major.to_string(), "major");
86        assert_eq!(Severity::None.to_string(), "none");
87    }
88
89    #[test]
90    fn orders_none_patch_minor_major_ascending() {
91        assert!(Severity::None < Severity::Patch);
92        assert!(Severity::Patch < Severity::Minor);
93        assert!(Severity::Minor < Severity::Major);
94    }
95
96    #[test]
97    fn max_of_mixed_severities_picks_highest() {
98        let severities = [Severity::Patch, Severity::None, Severity::Major, Severity::Minor];
99        assert_eq!(severities.iter().copied().max().unwrap(), Severity::Major);
100    }
101}