1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum GitStatusParseError {
10 Empty,
12 UnknownLabel,
14 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 #[must_use]
37 pub const fn as_str(self) -> &'static str {
38 match self {
39 $(Self::$variant => $label,)+
40 }
41 }
42
43 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
74pub enum GitIndexStatus {
75 Unmodified,
77 Added,
79 Modified,
81 Deleted,
83 Renamed,
85 Copied,
87 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
103pub enum GitWorktreeStatus {
104 Unmodified,
106 Modified,
108 Deleted,
110 Untracked,
112 Ignored,
114 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
129pub enum GitConflictStatus {
130 BothAdded,
132 BothModified,
134 BothDeleted,
136 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
149pub enum GitFileChange {
150 Added,
152 Modified,
154 Deleted,
156 Renamed,
158 Copied,
160 Untracked,
162 Ignored,
164 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#[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 #[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 #[must_use]
208 pub const fn with_index(mut self, index: GitIndexStatus) -> Self {
209 self.index = index;
210 self
211 }
212
213 #[must_use]
215 pub const fn with_worktree(mut self, worktree: GitWorktreeStatus) -> Self {
216 self.worktree = worktree;
217 self
218 }
219
220 #[must_use]
222 pub const fn with_conflict(mut self, conflict: GitConflictStatus) -> Self {
223 self.conflict = Some(conflict);
224 self
225 }
226
227 #[must_use]
229 pub const fn with_change(mut self, change: GitFileChange) -> Self {
230 self.change = Some(change);
231 self
232 }
233
234 #[must_use]
236 pub const fn index(&self) -> GitIndexStatus {
237 self.index
238 }
239
240 #[must_use]
242 pub const fn worktree(&self) -> GitWorktreeStatus {
243 self.worktree
244 }
245
246 #[must_use]
248 pub const fn conflict(&self) -> Option<GitConflictStatus> {
249 self.conflict
250 }
251
252 #[must_use]
254 pub const fn change(&self) -> Option<GitFileChange> {
255 self.change
256 }
257
258 #[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 #[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 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}