Skip to main content

use_git_status/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Error returned while parsing status vocabulary.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum GitStatusParseError {
10    /// The supplied label was empty.
11    Empty,
12    /// The supplied label was not recognized.
13    UnknownLabel,
14    /// The supplied porcelain code was not two characters.
15    InvalidPorcelainCode,
16}
17
18impl fmt::Display for GitStatusParseError {
19    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            Self::Empty => formatter.write_str("Git status label cannot be empty"),
22            Self::UnknownLabel => formatter.write_str("unknown Git status label"),
23            Self::InvalidPorcelainCode => {
24                formatter.write_str("porcelain status code must be two characters")
25            },
26        }
27    }
28}
29
30impl Error for GitStatusParseError {}
31
32macro_rules! status_enum {
33    ($name:ident { $($variant:ident => $label:literal, $code:literal);+ $(;)? }) => {
34        impl $name {
35            /// Returns the stable label.
36            #[must_use]
37            pub const fn as_str(self) -> &'static str {
38                match self {
39                    $(Self::$variant => $label,)+
40                }
41            }
42
43            /// Returns the porcelain status code character.
44            #[must_use]
45            pub const fn porcelain_char(self) -> char {
46                match self {
47                    $(Self::$variant => $code,)+
48                }
49            }
50        }
51
52        impl fmt::Display for $name {
53            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
54                formatter.write_str(self.as_str())
55            }
56        }
57
58        impl FromStr for $name {
59            type Err = GitStatusParseError;
60
61            fn from_str(value: &str) -> Result<Self, Self::Err> {
62                match value.trim().to_ascii_lowercase().as_str() {
63                    $($label => Ok(Self::$variant),)+
64                    "" => Err(GitStatusParseError::Empty),
65                    _ => Err(GitStatusParseError::UnknownLabel),
66                }
67            }
68        }
69    };
70}
71
72/// Index-side status vocabulary.
73#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
74pub enum GitIndexStatus {
75    /// No index-side change.
76    Unmodified,
77    /// Added in the index.
78    Added,
79    /// Modified in the index.
80    Modified,
81    /// Deleted in the index.
82    Deleted,
83    /// Renamed in the index.
84    Renamed,
85    /// Copied in the index.
86    Copied,
87    /// Unmerged or otherwise conflicted in the index.
88    Conflicted,
89}
90
91status_enum!(GitIndexStatus {
92    Unmodified => "unmodified", ' ';
93    Added => "added", 'A';
94    Modified => "modified", 'M';
95    Deleted => "deleted", 'D';
96    Renamed => "renamed", 'R';
97    Copied => "copied", 'C';
98    Conflicted => "conflicted", 'U';
99});
100
101/// Worktree-side status vocabulary.
102#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
103pub enum GitWorktreeStatus {
104    /// No worktree-side change.
105    Unmodified,
106    /// Modified in the worktree.
107    Modified,
108    /// Deleted in the worktree.
109    Deleted,
110    /// Untracked in the worktree.
111    Untracked,
112    /// Ignored in the worktree.
113    Ignored,
114    /// Conflicted in the worktree.
115    Conflicted,
116}
117
118status_enum!(GitWorktreeStatus {
119    Unmodified => "unmodified", ' ';
120    Modified => "modified", 'M';
121    Deleted => "deleted", 'D';
122    Untracked => "untracked", '?';
123    Ignored => "ignored", '!';
124    Conflicted => "conflicted", 'U';
125});
126
127/// Conflict status vocabulary.
128#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
129pub enum GitConflictStatus {
130    /// Both sides added a path.
131    BothAdded,
132    /// Both sides modified a path.
133    BothModified,
134    /// Both sides deleted a path.
135    BothDeleted,
136    /// One side deleted and the other modified a path.
137    DeletedByOneSide,
138}
139
140status_enum!(GitConflictStatus {
141    BothAdded => "both-added", 'A';
142    BothModified => "both-modified", 'U';
143    BothDeleted => "both-deleted", 'D';
144    DeletedByOneSide => "deleted-by-one-side", 'U';
145});
146
147/// File-change vocabulary.
148#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
149pub enum GitFileChange {
150    /// Added file.
151    Added,
152    /// Modified file.
153    Modified,
154    /// Deleted file.
155    Deleted,
156    /// Renamed file.
157    Renamed,
158    /// Copied file.
159    Copied,
160    /// Untracked file.
161    Untracked,
162    /// Ignored file.
163    Ignored,
164    /// Conflicted file.
165    Conflicted,
166}
167
168status_enum!(GitFileChange {
169    Added => "added", 'A';
170    Modified => "modified", 'M';
171    Deleted => "deleted", 'D';
172    Renamed => "renamed", 'R';
173    Copied => "copied", 'C';
174    Untracked => "untracked", '?';
175    Ignored => "ignored", '!';
176    Conflicted => "conflicted", 'U';
177});
178
179/// Combined status metadata.
180#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
181pub struct GitStatus {
182    index: GitIndexStatus,
183    worktree: GitWorktreeStatus,
184    conflict: Option<GitConflictStatus>,
185    change: Option<GitFileChange>,
186}
187
188impl Default for GitStatus {
189    fn default() -> Self {
190        Self::new()
191    }
192}
193
194impl GitStatus {
195    /// Creates a clean status metadata value.
196    #[must_use]
197    pub const fn new() -> Self {
198        Self {
199            index: GitIndexStatus::Unmodified,
200            worktree: GitWorktreeStatus::Unmodified,
201            conflict: None,
202            change: None,
203        }
204    }
205
206    /// Sets the index-side status.
207    #[must_use]
208    pub const fn with_index(mut self, index: GitIndexStatus) -> Self {
209        self.index = index;
210        self
211    }
212
213    /// Sets the worktree-side status.
214    #[must_use]
215    pub const fn with_worktree(mut self, worktree: GitWorktreeStatus) -> Self {
216        self.worktree = worktree;
217        self
218    }
219
220    /// Sets conflict status metadata.
221    #[must_use]
222    pub const fn with_conflict(mut self, conflict: GitConflictStatus) -> Self {
223        self.conflict = Some(conflict);
224        self
225    }
226
227    /// Sets file-change metadata.
228    #[must_use]
229    pub const fn with_change(mut self, change: GitFileChange) -> Self {
230        self.change = Some(change);
231        self
232    }
233
234    /// Returns index-side status.
235    #[must_use]
236    pub const fn index(&self) -> GitIndexStatus {
237        self.index
238    }
239
240    /// Returns worktree-side status.
241    #[must_use]
242    pub const fn worktree(&self) -> GitWorktreeStatus {
243        self.worktree
244    }
245
246    /// Returns conflict status metadata when present.
247    #[must_use]
248    pub const fn conflict(&self) -> Option<GitConflictStatus> {
249        self.conflict
250    }
251
252    /// Returns file-change metadata when present.
253    #[must_use]
254    pub const fn change(&self) -> Option<GitFileChange> {
255        self.change
256    }
257
258    /// Returns true when the status metadata is clean.
259    #[must_use]
260    pub const fn is_clean(&self) -> bool {
261        matches!(self.index, GitIndexStatus::Unmodified)
262            && matches!(self.worktree, GitWorktreeStatus::Unmodified)
263            && self.conflict.is_none()
264            && self.change.is_none()
265    }
266
267    /// Returns the two-character porcelain status code.
268    #[must_use]
269    pub fn porcelain_code(&self) -> String {
270        let mut code = String::with_capacity(2);
271        code.push(self.index.porcelain_char());
272        code.push(self.worktree.porcelain_char());
273        code
274    }
275
276    /// Creates status metadata from a two-character porcelain code.
277    ///
278    /// # Errors
279    ///
280    /// Returns [`GitStatusParseError`] when the code is not exactly two characters
281    /// or contains unsupported status characters.
282    pub fn from_porcelain_code(value: &str) -> Result<Self, GitStatusParseError> {
283        let mut chars = value.chars();
284        let Some(index) = chars.next() else {
285            return Err(GitStatusParseError::InvalidPorcelainCode);
286        };
287        let Some(worktree) = chars.next() else {
288            return Err(GitStatusParseError::InvalidPorcelainCode);
289        };
290        if chars.next().is_some() {
291            return Err(GitStatusParseError::InvalidPorcelainCode);
292        }
293
294        Ok(Self::new()
295            .with_index(parse_index_code(index)?)
296            .with_worktree(parse_worktree_code(worktree)?))
297    }
298}
299
300const fn parse_index_code(value: char) -> Result<GitIndexStatus, GitStatusParseError> {
301    match value {
302        ' ' => Ok(GitIndexStatus::Unmodified),
303        'A' => Ok(GitIndexStatus::Added),
304        'M' => Ok(GitIndexStatus::Modified),
305        'D' => Ok(GitIndexStatus::Deleted),
306        'R' => Ok(GitIndexStatus::Renamed),
307        'C' => Ok(GitIndexStatus::Copied),
308        'U' => Ok(GitIndexStatus::Conflicted),
309        _ => Err(GitStatusParseError::UnknownLabel),
310    }
311}
312
313const fn parse_worktree_code(value: char) -> Result<GitWorktreeStatus, GitStatusParseError> {
314    match value {
315        ' ' => Ok(GitWorktreeStatus::Unmodified),
316        'M' => Ok(GitWorktreeStatus::Modified),
317        'D' => Ok(GitWorktreeStatus::Deleted),
318        '?' => Ok(GitWorktreeStatus::Untracked),
319        '!' => Ok(GitWorktreeStatus::Ignored),
320        'U' => Ok(GitWorktreeStatus::Conflicted),
321        _ => Err(GitStatusParseError::UnknownLabel),
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::{GitIndexStatus, GitStatus, GitStatusParseError, GitWorktreeStatus};
328
329    #[test]
330    fn models_clean_and_modified_status() {
331        let clean = GitStatus::new();
332        let modified = clean.with_index(GitIndexStatus::Modified);
333
334        assert!(clean.is_clean());
335        assert!(!modified.is_clean());
336        assert_eq!(modified.porcelain_code(), "M ");
337    }
338
339    #[test]
340    fn parses_porcelain_codes() -> Result<(), GitStatusParseError> {
341        let status = GitStatus::from_porcelain_code(" M")?;
342
343        assert_eq!(status.index(), GitIndexStatus::Unmodified);
344        assert_eq!(status.worktree(), GitWorktreeStatus::Modified);
345        Ok(())
346    }
347
348    #[test]
349    fn rejects_bad_porcelain_codes() {
350        assert_eq!(
351            GitStatus::from_porcelain_code("M"),
352            Err(GitStatusParseError::InvalidPorcelainCode)
353        );
354        assert_eq!(
355            GitStatus::from_porcelain_code("ZZ"),
356            Err(GitStatusParseError::UnknownLabel)
357        );
358    }
359}