1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
//! See [`GitRef`] for the primary entry point.

use std::{error::Error, fmt};

/// Information about a specific ref in a git repository, analogous to the information
/// that `git show-ref` produces.
///
/// Callers should leverage all the information here for additional safety (for example, using
/// `git update-ref -d <name> <commit_id>` to only delete the reference if it matches the expected
/// commit ID).
#[derive(PartialEq, Eq, Hash, Debug, Clone)]
pub struct GitRef {
    /// The hash representing the git commit ID that the ref points to.
    pub commit_id: String,
    /// The full ref name, like `refs/heads/master`.
    pub name: String,
}

/// All the ways a `git show-ref` line can fail to parse.
#[derive(Debug, Eq, PartialEq)]
pub enum GitRefParseError {
    MissingName(String),
    MissingCommitId(String),
    TooManyParts(String),
}

impl fmt::Display for GitRefParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let (tag, line) = match self {
            Self::MissingName(line) => ("Missing name", line),
            Self::MissingCommitId(line) => ("Missing commit ID", line),
            Self::TooManyParts(line) => ("Too many parts", line),
        };

        write!(f, "{}: {}", tag, line)
    }
}

impl Error for GitRefParseError {}

// Use an `&S` to avoid compiler quirks: https://stackoverflow.com/a/63917951
fn is_not_empty<S: AsRef<str>>(str: &S) -> bool {
    !str.as_ref().is_empty()
}

impl GitRef {
    /// Utility to parse a `<ref_name><delimiter><commit_id>` line that git likes to output
    /// for various commands.
    fn parse_char_delimited_line(line: &str, delimiter: char) -> Result<GitRef, GitRefParseError> {
        let mut parts = line.split(delimiter).map(String::from).collect::<Vec<_>>();
        let name = parts
            .pop()
            .filter(is_not_empty)
            .ok_or_else(|| GitRefParseError::MissingName(line.to_string()))?;
        let commit_id = parts
            .pop()
            .filter(is_not_empty)
            .ok_or_else(|| GitRefParseError::MissingCommitId(line.to_string()))?;

        if !parts.is_empty() {
            return Err(GitRefParseError::TooManyParts(line.to_string()));
        }

        Ok(GitRef { commit_id, name })
    }

    /// Parse a single line from `git show-ref` as a [`GitRef`].
    pub fn parse_show_ref_line(line: &str) -> Result<GitRef, GitRefParseError> {
        Self::parse_char_delimited_line(line, ' ')
    }

    /// Parse a single line from `git ls-remote` as a [`GitRef`].
    pub fn parse_ls_remote_line(line: &str) -> Result<GitRef, GitRefParseError> {
        Self::parse_char_delimited_line(line, '\t')
    }
}

#[cfg(test)]
mod tests {
    use super::{GitRef, GitRefParseError};

    #[test]
    fn parse() {
        assert_eq!(
            GitRef::parse_show_ref_line("commit_id refs/heads/master"),
            Ok(GitRef {
                commit_id: "commit_id".to_string(),
                name: "refs/heads/master".to_string(),
            })
        );
    }

    fn parse_error<ErrFactory>(line: &str, err: ErrFactory)
    where
        ErrFactory: Fn(String) -> GitRefParseError,
    {
        assert_eq!(
            GitRef::parse_show_ref_line(line),
            Err(err(line.to_string()))
        );
    }

    #[test]
    fn parse_missing_name() {
        parse_error("", GitRefParseError::MissingName);
    }

    #[test]
    fn parse_missing_commit1() {
        parse_error("refs/heads/master", GitRefParseError::MissingCommitId);
    }

    #[test]
    fn parse_missing_commit2() {
        parse_error(" refs/heads/master", GitRefParseError::MissingCommitId);
    }

    #[test]
    fn parse_too_many() {
        parse_error(
            "extra commit_id refs/heads/master",
            GitRefParseError::TooManyParts,
        );
    }

    /// Checks that displaying any [`GitRefParseError`] always includes the string passed in.
    fn assert_display_contains_str(func: impl Fn(String) -> GitRefParseError) {
        let displayed = format!("{}", func("foo".to_string()));
        assert!(displayed.contains("foo"));
    }

    #[test]
    fn display_missing_name() {
        assert_display_contains_str(GitRefParseError::MissingName);
    }

    #[test]
    fn display_missing_commit_id() {
        assert_display_contains_str(GitRefParseError::MissingCommitId);
    }

    #[test]
    fn display_too_many_parts() {
        assert_display_contains_str(GitRefParseError::TooManyParts);
    }
}