#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitStatusParseError {
Empty,
UnknownLabel,
InvalidPorcelainCode,
}
impl fmt::Display for GitStatusParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("Git status label cannot be empty"),
Self::UnknownLabel => formatter.write_str("unknown Git status label"),
Self::InvalidPorcelainCode => {
formatter.write_str("porcelain status code must be two characters")
},
}
}
}
impl Error for GitStatusParseError {}
macro_rules! status_enum {
($name:ident { $($variant:ident => $label:literal, $code:literal);+ $(;)? }) => {
impl $name {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
$(Self::$variant => $label,)+
}
}
#[must_use]
pub const fn porcelain_char(self) -> char {
match self {
$(Self::$variant => $code,)+
}
}
}
impl fmt::Display for $name {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for $name {
type Err = GitStatusParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
$($label => Ok(Self::$variant),)+
"" => Err(GitStatusParseError::Empty),
_ => Err(GitStatusParseError::UnknownLabel),
}
}
}
};
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GitIndexStatus {
Unmodified,
Added,
Modified,
Deleted,
Renamed,
Copied,
Conflicted,
}
status_enum!(GitIndexStatus {
Unmodified => "unmodified", ' ';
Added => "added", 'A';
Modified => "modified", 'M';
Deleted => "deleted", 'D';
Renamed => "renamed", 'R';
Copied => "copied", 'C';
Conflicted => "conflicted", 'U';
});
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GitWorktreeStatus {
Unmodified,
Modified,
Deleted,
Untracked,
Ignored,
Conflicted,
}
status_enum!(GitWorktreeStatus {
Unmodified => "unmodified", ' ';
Modified => "modified", 'M';
Deleted => "deleted", 'D';
Untracked => "untracked", '?';
Ignored => "ignored", '!';
Conflicted => "conflicted", 'U';
});
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GitConflictStatus {
BothAdded,
BothModified,
BothDeleted,
DeletedByOneSide,
}
status_enum!(GitConflictStatus {
BothAdded => "both-added", 'A';
BothModified => "both-modified", 'U';
BothDeleted => "both-deleted", 'D';
DeletedByOneSide => "deleted-by-one-side", 'U';
});
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GitFileChange {
Added,
Modified,
Deleted,
Renamed,
Copied,
Untracked,
Ignored,
Conflicted,
}
status_enum!(GitFileChange {
Added => "added", 'A';
Modified => "modified", 'M';
Deleted => "deleted", 'D';
Renamed => "renamed", 'R';
Copied => "copied", 'C';
Untracked => "untracked", '?';
Ignored => "ignored", '!';
Conflicted => "conflicted", 'U';
});
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GitStatus {
index: GitIndexStatus,
worktree: GitWorktreeStatus,
conflict: Option<GitConflictStatus>,
change: Option<GitFileChange>,
}
impl Default for GitStatus {
fn default() -> Self {
Self::new()
}
}
impl GitStatus {
#[must_use]
pub const fn new() -> Self {
Self {
index: GitIndexStatus::Unmodified,
worktree: GitWorktreeStatus::Unmodified,
conflict: None,
change: None,
}
}
#[must_use]
pub const fn with_index(mut self, index: GitIndexStatus) -> Self {
self.index = index;
self
}
#[must_use]
pub const fn with_worktree(mut self, worktree: GitWorktreeStatus) -> Self {
self.worktree = worktree;
self
}
#[must_use]
pub const fn with_conflict(mut self, conflict: GitConflictStatus) -> Self {
self.conflict = Some(conflict);
self
}
#[must_use]
pub const fn with_change(mut self, change: GitFileChange) -> Self {
self.change = Some(change);
self
}
#[must_use]
pub const fn index(&self) -> GitIndexStatus {
self.index
}
#[must_use]
pub const fn worktree(&self) -> GitWorktreeStatus {
self.worktree
}
#[must_use]
pub const fn conflict(&self) -> Option<GitConflictStatus> {
self.conflict
}
#[must_use]
pub const fn change(&self) -> Option<GitFileChange> {
self.change
}
#[must_use]
pub const fn is_clean(&self) -> bool {
matches!(self.index, GitIndexStatus::Unmodified)
&& matches!(self.worktree, GitWorktreeStatus::Unmodified)
&& self.conflict.is_none()
&& self.change.is_none()
}
#[must_use]
pub fn porcelain_code(&self) -> String {
let mut code = String::with_capacity(2);
code.push(self.index.porcelain_char());
code.push(self.worktree.porcelain_char());
code
}
pub fn from_porcelain_code(value: &str) -> Result<Self, GitStatusParseError> {
let mut chars = value.chars();
let Some(index) = chars.next() else {
return Err(GitStatusParseError::InvalidPorcelainCode);
};
let Some(worktree) = chars.next() else {
return Err(GitStatusParseError::InvalidPorcelainCode);
};
if chars.next().is_some() {
return Err(GitStatusParseError::InvalidPorcelainCode);
}
Ok(Self::new()
.with_index(parse_index_code(index)?)
.with_worktree(parse_worktree_code(worktree)?))
}
}
const fn parse_index_code(value: char) -> Result<GitIndexStatus, GitStatusParseError> {
match value {
' ' => Ok(GitIndexStatus::Unmodified),
'A' => Ok(GitIndexStatus::Added),
'M' => Ok(GitIndexStatus::Modified),
'D' => Ok(GitIndexStatus::Deleted),
'R' => Ok(GitIndexStatus::Renamed),
'C' => Ok(GitIndexStatus::Copied),
'U' => Ok(GitIndexStatus::Conflicted),
_ => Err(GitStatusParseError::UnknownLabel),
}
}
const fn parse_worktree_code(value: char) -> Result<GitWorktreeStatus, GitStatusParseError> {
match value {
' ' => Ok(GitWorktreeStatus::Unmodified),
'M' => Ok(GitWorktreeStatus::Modified),
'D' => Ok(GitWorktreeStatus::Deleted),
'?' => Ok(GitWorktreeStatus::Untracked),
'!' => Ok(GitWorktreeStatus::Ignored),
'U' => Ok(GitWorktreeStatus::Conflicted),
_ => Err(GitStatusParseError::UnknownLabel),
}
}
#[cfg(test)]
mod tests {
use super::{GitIndexStatus, GitStatus, GitStatusParseError, GitWorktreeStatus};
#[test]
fn models_clean_and_modified_status() {
let clean = GitStatus::new();
let modified = clean.with_index(GitIndexStatus::Modified);
assert!(clean.is_clean());
assert!(!modified.is_clean());
assert_eq!(modified.porcelain_code(), "M ");
}
#[test]
fn parses_porcelain_codes() -> Result<(), GitStatusParseError> {
let status = GitStatus::from_porcelain_code(" M")?;
assert_eq!(status.index(), GitIndexStatus::Unmodified);
assert_eq!(status.worktree(), GitWorktreeStatus::Modified);
Ok(())
}
#[test]
fn rejects_bad_porcelain_codes() {
assert_eq!(
GitStatus::from_porcelain_code("M"),
Err(GitStatusParseError::InvalidPorcelainCode)
);
assert_eq!(
GitStatus::from_porcelain_code("ZZ"),
Err(GitStatusParseError::UnknownLabel)
);
}
}