gitstack 5.3.0

Git history viewer with insights - Author stats, file heatmap, code ownership
Documentation
//! Git type definitions (BranchInfo, FileStatus, CommitDiff, FilePatch, etc.)

use chrono::Local;

/// Branch information (name + stale detection)
#[derive(Debug, Clone)]
pub struct BranchInfo {
    pub name: String,
    /// Deleted on remote (upstream is configured but the actual branch no longer exists)
    pub is_gone: bool,
}

impl BranchInfo {
    pub fn new(name: String, is_gone: bool) -> Self {
        Self { name, is_gone }
    }
}

impl std::fmt::Display for BranchInfo {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.name)
    }
}

impl PartialEq<str> for BranchInfo {
    fn eq(&self, other: &str) -> bool {
        self.name == other
    }
}

impl PartialEq<String> for BranchInfo {
    fn eq(&self, other: &String) -> bool {
        self.name == *other
    }
}

/// File status kind
#[derive(Debug, Clone, PartialEq)]
pub enum FileStatusKind {
    /// Staged (newly added)
    StagedNew,
    /// Staged (modified)
    StagedModified,
    /// Staged (deleted)
    StagedDeleted,
    /// Unstaged (modified)
    Modified,
    /// Unstaged (deleted)
    Deleted,
    /// Untracked
    Untracked,
}

impl FileStatusKind {
    /// Check whether the file is staged
    pub fn is_staged(&self) -> bool {
        matches!(
            self,
            FileStatusKind::StagedNew
                | FileStatusKind::StagedModified
                | FileStatusKind::StagedDeleted
        )
    }
}

/// File status
#[derive(Debug, Clone)]
pub struct FileStatus {
    /// File path
    pub path: String,
    /// Status kind
    pub kind: FileStatusKind,
}

/// File change statistics for a commit
#[derive(Debug, Clone, Default)]
pub struct DiffStats {
    pub files_changed: usize,
    pub insertions: usize,
    pub deletions: usize,
}

/// File change status
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FileChangeStatus {
    /// Added
    Added,
    /// Modified
    Modified,
    /// Deleted
    Deleted,
    /// Renamed
    Renamed,
}

impl FileChangeStatus {
    /// Get the abbreviated character for display
    pub fn as_char(&self) -> char {
        match self {
            Self::Added => 'A',
            Self::Modified => 'M',
            Self::Deleted => 'D',
            Self::Renamed => 'R',
        }
    }
}

/// Individual file change within a commit
#[derive(Debug, Clone)]
pub struct FileChange {
    /// File path
    pub path: String,
    /// Modified status
    pub status: FileChangeStatus,
    /// Number of inserted lines
    pub insertions: usize,
    /// Number of deleted lines
    pub deletions: usize,
}

/// Commit diff information (statistics + file list)
#[derive(Debug, Clone, Default)]
pub struct CommitDiff {
    /// Statistics
    pub stats: DiffStats,
    /// List of file changes
    pub files: Vec<FileChange>,
    /// Diff patches per file
    pub patches: Vec<FilePatch>,
}

/// Repository information
#[derive(Debug, Clone)]
pub struct RepoInfo {
    /// Repository name
    pub name: String,
    /// Current branch name
    pub branch: String,
}

/// Data for a single diff line
#[derive(Debug, Clone)]
pub struct DiffLine {
    /// Line kind ('+': addition, '-': deletion, ' ': context/unchanged)
    pub origin: char,
    /// Line content
    pub content: String,
    /// Old file line number (valid for deletion and context lines)
    pub old_lineno: Option<u32>,
    /// New file line number (valid for addition and context lines)
    pub new_lineno: Option<u32>,
}

/// File patch information
#[derive(Debug, Clone)]
pub struct FilePatch {
    /// File path
    pub path: String,
    /// List of patch lines
    pub lines: Vec<DiffLine>,
}

/// Data for a single blame line
#[derive(Debug, Clone)]
pub struct BlameLine {
    /// Commit hash (abbreviated)
    pub hash: String,
    /// Author name
    pub author: String,
    /// Commit date/time
    pub date: chrono::DateTime<Local>,
    /// Line number (1-based)
    pub line_number: usize,
    /// Line content
    pub content: String,
}

/// File history entry
#[derive(Debug, Clone)]
pub struct FileHistoryEntry {
    /// Commit hash (abbreviated)
    pub hash: String,
    /// Author name
    pub author: String,
    /// Commit date/time
    pub date: chrono::DateTime<Local>,
    /// Commit message (first line)
    pub message: String,
    /// Number of inserted lines
    pub insertions: usize,
    /// Number of deleted lines
    pub deletions: usize,
}

/// Stash entry
#[derive(Debug, Clone)]
pub struct StashEntry {
    /// Index (0-based)
    pub index: usize,
    /// Message
    pub message: String,
}

#[cfg(test)]
mod tests {
    use super::*;

    // ===== BranchInfo =====

    #[test]
    fn test_branch_info_new() {
        let b = BranchInfo::new("main".to_string(), false);
        assert_eq!(b.name, "main");
        assert!(!b.is_gone);
    }

    #[test]
    fn test_branch_info_new_gone() {
        let b = BranchInfo::new("stale-branch".to_string(), true);
        assert_eq!(b.name, "stale-branch");
        assert!(b.is_gone);
    }

    #[test]
    fn test_branch_info_display() {
        let b = BranchInfo::new("feature/test".to_string(), false);
        assert_eq!(format!("{}", b), "feature/test");
    }

    #[test]
    fn test_branch_info_partial_eq_str() {
        let b = BranchInfo::new("main".to_string(), false);
        assert!(b == *"main");
        assert!(!(b == *"develop"));
    }

    #[test]
    fn test_branch_info_partial_eq_string() {
        let b = BranchInfo::new("main".to_string(), false);
        let main_str = "main".to_string();
        let develop_str = "develop".to_string();
        assert!(b == main_str);
        assert!(!(b == develop_str));
    }

    // ===== FileStatusKind =====

    #[test]
    fn test_file_status_kind_is_staged() {
        assert!(FileStatusKind::StagedNew.is_staged());
        assert!(FileStatusKind::StagedModified.is_staged());
        assert!(FileStatusKind::StagedDeleted.is_staged());
        assert!(!FileStatusKind::Modified.is_staged());
        assert!(!FileStatusKind::Deleted.is_staged());
        assert!(!FileStatusKind::Untracked.is_staged());
    }

    // ===== FileChangeStatus =====

    #[test]
    fn test_file_change_status_as_char() {
        assert_eq!(FileChangeStatus::Added.as_char(), 'A');
        assert_eq!(FileChangeStatus::Modified.as_char(), 'M');
        assert_eq!(FileChangeStatus::Deleted.as_char(), 'D');
        assert_eq!(FileChangeStatus::Renamed.as_char(), 'R');
    }

    // ===== DiffStats =====

    #[test]
    fn test_diff_stats_default() {
        let s = DiffStats::default();
        assert_eq!(s.files_changed, 0);
        assert_eq!(s.insertions, 0);
        assert_eq!(s.deletions, 0);
    }

    // ===== CommitDiff =====

    #[test]
    fn test_commit_diff_default() {
        let d = CommitDiff::default();
        assert_eq!(d.stats.files_changed, 0);
        assert!(d.files.is_empty());
        assert!(d.patches.is_empty());
    }

    // ===== StashEntry =====

    #[test]
    fn test_stash_entry_construction() {
        let e = StashEntry {
            index: 0,
            message: "WIP: feature".to_string(),
        };
        assert_eq!(e.index, 0);
        assert_eq!(e.message, "WIP: feature");
    }

    // ===== FilePatch =====

    #[test]
    fn test_file_patch_construction() {
        let p = FilePatch {
            path: "src/main.rs".to_string(),
            lines: vec![DiffLine {
                origin: '+',
                content: "new line".to_string(),
                old_lineno: None,
                new_lineno: Some(10),
            }],
        };
        assert_eq!(p.path, "src/main.rs");
        assert_eq!(p.lines.len(), 1);
        assert_eq!(p.lines[0].origin, '+');
        assert_eq!(p.lines[0].new_lineno, Some(10));
        assert_eq!(p.lines[0].old_lineno, None);
    }

    // ===== FileStatus =====

    #[test]
    fn test_file_status_construction() {
        let s = FileStatus {
            path: "README.md".to_string(),
            kind: FileStatusKind::Modified,
        };
        assert_eq!(s.path, "README.md");
        assert_eq!(s.kind, FileStatusKind::Modified);
    }

    // ===== FileChange =====

    #[test]
    fn test_file_change_construction() {
        let c = FileChange {
            path: "lib.rs".to_string(),
            status: FileChangeStatus::Added,
            insertions: 50,
            deletions: 0,
        };
        assert_eq!(c.path, "lib.rs");
        assert_eq!(c.status, FileChangeStatus::Added);
        assert_eq!(c.insertions, 50);
        assert_eq!(c.deletions, 0);
    }

    // ===== RepoInfo =====

    #[test]
    fn test_repo_info_construction() {
        let r = RepoInfo {
            name: "my-repo".to_string(),
            branch: "main".to_string(),
        };
        assert_eq!(r.name, "my-repo");
        assert_eq!(r.branch, "main");
    }
}