Skip to main content

jar_genesis/
hash.rs

1use thiserror::Error;
2
3#[derive(Debug, Error)]
4pub enum HashError {
5    #[error("no match for short hash '{0}'")]
6    NoMatch(String),
7    #[error("ambiguous short hash '{0}': matches {1:?}")]
8    Ambiguous(String, Vec<String>),
9    #[error("invalid hex: '{0}'")]
10    InvalidHex(String),
11}
12
13/// Strip the GitHub commit URL prefix if present.
14/// `https://github.com/owner/repo/commit/abc123...` → `abc123...`
15pub fn normalize_commit_ref(s: &str) -> String {
16    // Match pattern: https://github.com/<owner>/<repo>/commit/<hash>
17    if let Some(idx) = s.find("/commit/") {
18        s[idx + "/commit/".len()..].to_string()
19    } else {
20        s.to_string()
21    }
22}
23
24/// Check if a string is a valid 40-character lowercase hex hash.
25pub fn is_valid_hex_hash(s: &str) -> bool {
26    s.len() == 40
27        && s.chars()
28            .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
29}
30
31/// Strip carriage returns from a string.
32pub fn strip_carriage_returns(s: &str) -> String {
33    s.replace('\r', "")
34}
35
36/// Expand a short hash (typically 8 chars) to a full 40-char hash
37/// by prefix-matching against a list of candidates.
38pub fn expand_short_hash(short: &str, candidates: &[String]) -> Result<String, HashError> {
39    // Already full length — pass through.
40    if short.len() == 40 {
41        if is_valid_hex_hash(short) {
42            return Ok(short.to_string());
43        }
44        return Err(HashError::InvalidHex(short.to_string()));
45    }
46
47    // Validate hex.
48    if !short.chars().all(|c| c.is_ascii_hexdigit()) {
49        return Err(HashError::InvalidHex(short.to_string()));
50    }
51
52    let matches: Vec<String> = candidates
53        .iter()
54        .filter(|c| c.starts_with(short))
55        .cloned()
56        .collect();
57
58    match matches.len() {
59        0 => Err(HashError::NoMatch(short.to_string())),
60        1 => Ok(matches.into_iter().next().unwrap()),
61        _ => Err(HashError::Ambiguous(short.to_string(), matches)),
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn test_expand_short_hash_unique_match() {
71        let candidates = vec![
72            "204e93abf18ab00e339d92787c6f807269517cdf".to_string(),
73            "b012110bedc7f0ffca3ae37f38915afbc229c26e".to_string(),
74        ];
75        let result = expand_short_hash("204e93ab", &candidates).unwrap();
76        assert_eq!(result, "204e93abf18ab00e339d92787c6f807269517cdf");
77    }
78
79    #[test]
80    fn test_expand_short_hash_no_match() {
81        let candidates = vec!["204e93abf18ab00e339d92787c6f807269517cdf".to_string()];
82        let result = expand_short_hash("deadbeef", &candidates);
83        assert!(matches!(result, Err(HashError::NoMatch(_))));
84    }
85
86    #[test]
87    fn test_expand_short_hash_ambiguous() {
88        let candidates = vec![
89            "204e93abf18ab00e339d92787c6f807269517cdf".to_string(),
90            "204e93ab0000000000000000000000000000000000".to_string(),
91        ];
92        let result = expand_short_hash("204e93ab", &candidates);
93        assert!(matches!(result, Err(HashError::Ambiguous(_, _))));
94    }
95
96    #[test]
97    fn test_expand_full_hash_passthrough() {
98        let candidates = vec![];
99        let hash = "204e93abf18ab00e339d92787c6f807269517cdf";
100        let result = expand_short_hash(hash, &candidates).unwrap();
101        assert_eq!(result, hash);
102    }
103
104    #[test]
105    fn test_expand_invalid_hex() {
106        let candidates = vec![];
107        let result = expand_short_hash("not_hex!", &candidates);
108        assert!(matches!(result, Err(HashError::InvalidHex(_))));
109    }
110
111    #[test]
112    fn test_normalize_commit_ref_url() {
113        let url = "https://github.com/jarchain/jar/commit/204e93abf18ab00e339d92787c6f807269517cdf";
114        assert_eq!(
115            normalize_commit_ref(url),
116            "204e93abf18ab00e339d92787c6f807269517cdf"
117        );
118    }
119
120    #[test]
121    fn test_normalize_commit_ref_bare_hash() {
122        let hash = "204e93abf18ab00e339d92787c6f807269517cdf";
123        assert_eq!(normalize_commit_ref(hash), hash);
124    }
125
126    #[test]
127    fn test_is_valid_hex_hash() {
128        assert!(is_valid_hex_hash(
129            "204e93abf18ab00e339d92787c6f807269517cdf"
130        ));
131        assert!(!is_valid_hex_hash("too_short"));
132        assert!(!is_valid_hex_hash(
133            "204E93ABF18AB00E339D92787C6F807269517CDF"
134        )); // uppercase
135        assert!(!is_valid_hex_hash(
136            "204e93abf18ab00e339d92787c6f807269517cdX"
137        )); // non-hex
138    }
139
140    #[test]
141    fn test_strip_carriage_returns() {
142        assert_eq!(strip_carriage_returns("merge\r"), "merge");
143        assert_eq!(strip_carriage_returns("merge"), "merge");
144        assert_eq!(strip_carriage_returns("a\rb\rc\r"), "abc");
145    }
146
147    #[test]
148    fn test_normalize_different_repo_url() {
149        let url = "https://github.com/other-org/other-repo/commit/abcdef1234567890abcdef1234567890abcdef12";
150        assert_eq!(
151            normalize_commit_ref(url),
152            "abcdef1234567890abcdef1234567890abcdef12"
153        );
154    }
155
156    #[test]
157    fn test_normalize_no_commit_path() {
158        // URL without /commit/ should pass through
159        let url = "https://github.com/jarchain/jar/pull/95";
160        assert_eq!(normalize_commit_ref(url), url);
161    }
162
163    #[test]
164    fn test_expand_empty_short_hash() {
165        let candidates = vec!["abcdef1234567890abcdef1234567890abcdef12".to_string()];
166        // Empty string is technically valid hex (0 chars) but won't match
167        // because every candidate starts with it — should be ambiguous if multiple
168        let result = expand_short_hash("", &candidates);
169        // Empty string matches everything by prefix — but it's also valid hex (vacuously)
170        assert!(result.is_ok() || matches!(result, Err(HashError::Ambiguous(_, _))));
171    }
172
173    #[test]
174    fn test_expand_full_hash_invalid() {
175        // 40 chars but contains non-hex
176        let candidates = vec![];
177        let hash = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
178        let result = expand_short_hash(hash, &candidates);
179        assert!(matches!(result, Err(HashError::InvalidHex(_))));
180    }
181
182    #[test]
183    fn test_is_valid_hex_hash_empty() {
184        assert!(!is_valid_hex_hash(""));
185    }
186
187    #[test]
188    fn test_is_valid_hex_hash_39_chars() {
189        assert!(!is_valid_hex_hash(
190            "204e93abf18ab00e339d92787c6f807269517cd"
191        ));
192    }
193}