use-git-status 0.0.1

Primitive Git status metadata vocabulary for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

use core::{fmt, str::FromStr};
use std::error::Error;

/// Error returned while parsing status vocabulary.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitStatusParseError {
    /// The supplied label was empty.
    Empty,
    /// The supplied label was not recognized.
    UnknownLabel,
    /// The supplied porcelain code was not two characters.
    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 {
            /// Returns the stable label.
            #[must_use]
            pub const fn as_str(self) -> &'static str {
                match self {
                    $(Self::$variant => $label,)+
                }
            }

            /// Returns the porcelain status code character.
            #[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),
                }
            }
        }
    };
}

/// Index-side status vocabulary.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GitIndexStatus {
    /// No index-side change.
    Unmodified,
    /// Added in the index.
    Added,
    /// Modified in the index.
    Modified,
    /// Deleted in the index.
    Deleted,
    /// Renamed in the index.
    Renamed,
    /// Copied in the index.
    Copied,
    /// Unmerged or otherwise conflicted in the index.
    Conflicted,
}

status_enum!(GitIndexStatus {
    Unmodified => "unmodified", ' ';
    Added => "added", 'A';
    Modified => "modified", 'M';
    Deleted => "deleted", 'D';
    Renamed => "renamed", 'R';
    Copied => "copied", 'C';
    Conflicted => "conflicted", 'U';
});

/// Worktree-side status vocabulary.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GitWorktreeStatus {
    /// No worktree-side change.
    Unmodified,
    /// Modified in the worktree.
    Modified,
    /// Deleted in the worktree.
    Deleted,
    /// Untracked in the worktree.
    Untracked,
    /// Ignored in the worktree.
    Ignored,
    /// Conflicted in the worktree.
    Conflicted,
}

status_enum!(GitWorktreeStatus {
    Unmodified => "unmodified", ' ';
    Modified => "modified", 'M';
    Deleted => "deleted", 'D';
    Untracked => "untracked", '?';
    Ignored => "ignored", '!';
    Conflicted => "conflicted", 'U';
});

/// Conflict status vocabulary.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GitConflictStatus {
    /// Both sides added a path.
    BothAdded,
    /// Both sides modified a path.
    BothModified,
    /// Both sides deleted a path.
    BothDeleted,
    /// One side deleted and the other modified a path.
    DeletedByOneSide,
}

status_enum!(GitConflictStatus {
    BothAdded => "both-added", 'A';
    BothModified => "both-modified", 'U';
    BothDeleted => "both-deleted", 'D';
    DeletedByOneSide => "deleted-by-one-side", 'U';
});

/// File-change vocabulary.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GitFileChange {
    /// Added file.
    Added,
    /// Modified file.
    Modified,
    /// Deleted file.
    Deleted,
    /// Renamed file.
    Renamed,
    /// Copied file.
    Copied,
    /// Untracked file.
    Untracked,
    /// Ignored file.
    Ignored,
    /// Conflicted file.
    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';
});

/// Combined status metadata.
#[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 {
    /// Creates a clean status metadata value.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            index: GitIndexStatus::Unmodified,
            worktree: GitWorktreeStatus::Unmodified,
            conflict: None,
            change: None,
        }
    }

    /// Sets the index-side status.
    #[must_use]
    pub const fn with_index(mut self, index: GitIndexStatus) -> Self {
        self.index = index;
        self
    }

    /// Sets the worktree-side status.
    #[must_use]
    pub const fn with_worktree(mut self, worktree: GitWorktreeStatus) -> Self {
        self.worktree = worktree;
        self
    }

    /// Sets conflict status metadata.
    #[must_use]
    pub const fn with_conflict(mut self, conflict: GitConflictStatus) -> Self {
        self.conflict = Some(conflict);
        self
    }

    /// Sets file-change metadata.
    #[must_use]
    pub const fn with_change(mut self, change: GitFileChange) -> Self {
        self.change = Some(change);
        self
    }

    /// Returns index-side status.
    #[must_use]
    pub const fn index(&self) -> GitIndexStatus {
        self.index
    }

    /// Returns worktree-side status.
    #[must_use]
    pub const fn worktree(&self) -> GitWorktreeStatus {
        self.worktree
    }

    /// Returns conflict status metadata when present.
    #[must_use]
    pub const fn conflict(&self) -> Option<GitConflictStatus> {
        self.conflict
    }

    /// Returns file-change metadata when present.
    #[must_use]
    pub const fn change(&self) -> Option<GitFileChange> {
        self.change
    }

    /// Returns true when the status metadata is clean.
    #[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()
    }

    /// Returns the two-character porcelain status code.
    #[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
    }

    /// Creates status metadata from a two-character porcelain code.
    ///
    /// # Errors
    ///
    /// Returns [`GitStatusParseError`] when the code is not exactly two characters
    /// or contains unsupported status characters.
    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)
        );
    }
}