Skip to main content

sqry_core/schema/
change.rs

1//! Canonical change kind enumeration.
2//!
3//! Defines types of changes detected by semantic diff.
4
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8/// Types of changes detected by semantic diff.
9///
10/// Used by `semantic_diff` to categorize differences between
11/// two versions of a codebase.
12///
13/// # Serialization
14///
15/// All variants serialize to `snake_case`: `"added"`, `"signature_changed"`, etc.
16///
17/// # Examples
18///
19/// ```
20/// use sqry_core::schema::ChangeKind;
21///
22/// let kind = ChangeKind::SignatureChanged;
23/// assert_eq!(kind.as_str(), "signature_changed");
24///
25/// let parsed = ChangeKind::parse("modified").unwrap();
26/// assert_eq!(parsed, ChangeKind::Modified);
27/// ```
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30#[derive(Default)]
31pub enum ChangeKind {
32    /// Node was added (exists in target, not in base).
33    Added,
34
35    /// Node was removed (exists in base, not in target).
36    Removed,
37
38    /// Node was modified (implementation changed, signature same).
39    #[default]
40    Modified,
41
42    /// Node was renamed (same implementation, different name).
43    Renamed,
44
45    /// Node signature changed (parameters, return type, etc.).
46    SignatureChanged,
47}
48
49impl ChangeKind {
50    /// Returns all variants in definition order.
51    #[must_use]
52    pub const fn all() -> &'static [Self] {
53        &[
54            Self::Added,
55            Self::Removed,
56            Self::Modified,
57            Self::Renamed,
58            Self::SignatureChanged,
59        ]
60    }
61
62    /// Returns the canonical string representation.
63    #[must_use]
64    pub const fn as_str(self) -> &'static str {
65        match self {
66            Self::Added => "added",
67            Self::Removed => "removed",
68            Self::Modified => "modified",
69            Self::Renamed => "renamed",
70            Self::SignatureChanged => "signature_changed",
71        }
72    }
73
74    /// Parses a string into a `ChangeKind`.
75    ///
76    /// Returns `None` if the string doesn't match any known kind.
77    /// Case-insensitive, accepts both `snake_case` and lowercase.
78    #[must_use]
79    pub fn parse(s: &str) -> Option<Self> {
80        match s.to_lowercase().replace('-', "_").as_str() {
81            "added" | "new" => Some(Self::Added),
82            "removed" | "deleted" => Some(Self::Removed),
83            "modified" | "changed" => Some(Self::Modified),
84            "renamed" => Some(Self::Renamed),
85            "signature_changed" | "signaturechanged" => Some(Self::SignatureChanged),
86            _ => None,
87        }
88    }
89
90    /// Returns `true` if this is a structural change (added/removed).
91    #[must_use]
92    pub const fn is_structural(self) -> bool {
93        matches!(self, Self::Added | Self::Removed)
94    }
95
96    /// Returns `true` if this is a content change (modified/renamed/signature).
97    #[must_use]
98    pub const fn is_content_change(self) -> bool {
99        matches!(
100            self,
101            Self::Modified | Self::Renamed | Self::SignatureChanged
102        )
103    }
104
105    /// Returns `true` if this change affects the public API.
106    #[must_use]
107    pub const fn affects_api(self) -> bool {
108        matches!(
109            self,
110            Self::Added | Self::Removed | Self::Renamed | Self::SignatureChanged
111        )
112    }
113}
114
115impl fmt::Display for ChangeKind {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        f.write_str(self.as_str())
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn test_as_str() {
127        assert_eq!(ChangeKind::Added.as_str(), "added");
128        assert_eq!(ChangeKind::Removed.as_str(), "removed");
129        assert_eq!(ChangeKind::Modified.as_str(), "modified");
130        assert_eq!(ChangeKind::Renamed.as_str(), "renamed");
131        assert_eq!(ChangeKind::SignatureChanged.as_str(), "signature_changed");
132    }
133
134    #[test]
135    fn test_parse() {
136        assert_eq!(ChangeKind::parse("added"), Some(ChangeKind::Added));
137        assert_eq!(ChangeKind::parse("REMOVED"), Some(ChangeKind::Removed));
138        assert_eq!(ChangeKind::parse("new"), Some(ChangeKind::Added));
139        assert_eq!(ChangeKind::parse("deleted"), Some(ChangeKind::Removed));
140        assert_eq!(
141            ChangeKind::parse("signature_changed"),
142            Some(ChangeKind::SignatureChanged)
143        );
144        assert_eq!(ChangeKind::parse("unknown"), None);
145    }
146
147    #[test]
148    fn test_display() {
149        assert_eq!(format!("{}", ChangeKind::Added), "added");
150        assert_eq!(
151            format!("{}", ChangeKind::SignatureChanged),
152            "signature_changed"
153        );
154    }
155
156    #[test]
157    fn test_serde_roundtrip() {
158        for kind in ChangeKind::all() {
159            let json = serde_json::to_string(kind).unwrap();
160            let deserialized: ChangeKind = serde_json::from_str(&json).unwrap();
161            assert_eq!(*kind, deserialized);
162        }
163    }
164
165    #[test]
166    fn test_classification() {
167        assert!(ChangeKind::Added.is_structural());
168        assert!(ChangeKind::Removed.is_structural());
169        assert!(!ChangeKind::Modified.is_structural());
170
171        assert!(ChangeKind::Modified.is_content_change());
172        assert!(ChangeKind::Renamed.is_content_change());
173        assert!(!ChangeKind::Added.is_content_change());
174
175        assert!(ChangeKind::Added.affects_api());
176        assert!(ChangeKind::Removed.affects_api());
177        assert!(ChangeKind::Renamed.affects_api());
178        assert!(ChangeKind::SignatureChanged.affects_api());
179        assert!(!ChangeKind::Modified.affects_api());
180    }
181}