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