hindsight_git/
commit.rs

1// Copyright (c) 2026 - present Nicholas D. Crosbie
2// SPDX-License-Identifier: MIT
3
4//! Git commit types and operations
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// Represents a parsed git commit
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct Commit {
12    /// The commit SHA (40 hex characters)
13    pub sha: String,
14    /// Commit message
15    pub message: String,
16    /// Author name
17    pub author: String,
18    /// Author email
19    pub author_email: String,
20    /// Commit timestamp
21    pub timestamp: DateTime<Utc>,
22    /// Parent commit SHAs
23    pub parents: Vec<String>,
24}
25
26impl Commit {
27    /// Validate that a SHA is a valid 40-character hex string
28    #[must_use]
29    pub fn is_valid_sha(sha: &str) -> bool {
30        sha.len() == 40 && sha.chars().all(|c| c.is_ascii_hexdigit())
31    }
32
33    /// Get the short SHA (first 7 characters)
34    #[must_use]
35    pub fn short_sha(&self) -> &str {
36        &self.sha[..7.min(self.sha.len())]
37    }
38
39    /// Check if this is a merge commit (has multiple parents)
40    #[must_use]
41    pub fn is_merge(&self) -> bool {
42        self.parents.len() > 1
43    }
44
45    /// Check if this is a root commit (has no parents)
46    #[must_use]
47    pub fn is_root(&self) -> bool {
48        self.parents.is_empty()
49    }
50
51    /// Get the first line of the commit message (subject)
52    #[must_use]
53    pub fn subject(&self) -> &str {
54        self.message.lines().next().unwrap_or("")
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use chrono::TimeZone;
62    use similar_asserts::assert_eq;
63
64    fn sample_commit() -> Commit {
65        Commit {
66            sha: "1945ab9c752534e733c38ba0109dc3b741f0a6eb".to_string(),
67            message: "feat(skills): add milestone-creator\n\nDetailed description here."
68                .to_string(),
69            author: "Test Author".to_string(),
70            author_email: "test@example.com".to_string(),
71            timestamp: Utc.with_ymd_and_hms(2026, 1, 17, 2, 33, 6).unwrap(),
72            parents: vec!["c460aeb7fb2d109c17e43de0ce681faec0b7374d".to_string()],
73        }
74    }
75
76    #[test]
77    fn test_commit_serialization_roundtrip() {
78        let commit = sample_commit();
79        let json = serde_json::to_string(&commit).expect("serialize");
80        let deserialized: Commit = serde_json::from_str(&json).expect("deserialize");
81        assert_eq!(commit, deserialized);
82    }
83
84    #[test]
85    fn test_commit_json_format() {
86        let commit = sample_commit();
87        let json = serde_json::to_string_pretty(&commit).expect("serialize");
88        assert!(json.contains("\"sha\":"));
89        assert!(json.contains("1945ab9c752534e733c38ba0109dc3b741f0a6eb"));
90        assert!(json.contains("\"timestamp\":"));
91    }
92
93    #[test]
94    fn test_is_valid_sha_valid() {
95        assert!(Commit::is_valid_sha(
96            "1945ab9c752534e733c38ba0109dc3b741f0a6eb"
97        ));
98        assert!(Commit::is_valid_sha(
99            "0000000000000000000000000000000000000000"
100        ));
101        assert!(Commit::is_valid_sha(
102            "ffffffffffffffffffffffffffffffffffffffff"
103        ));
104        assert!(Commit::is_valid_sha(
105            "ABCDEF1234567890abcdef1234567890abcdef12"
106        ));
107    }
108
109    #[test]
110    fn test_is_valid_sha_invalid() {
111        // Too short
112        assert!(!Commit::is_valid_sha("1945ab9"));
113        // Too long
114        assert!(!Commit::is_valid_sha(
115            "1945ab9c752534e733c38ba0109dc3b741f0a6eb0"
116        ));
117        // Invalid characters
118        assert!(!Commit::is_valid_sha(
119            "1945ab9c752534e733c38ba0109dc3b741f0a6eg"
120        ));
121        // Empty
122        assert!(!Commit::is_valid_sha(""));
123    }
124
125    #[test]
126    fn test_short_sha() {
127        let commit = sample_commit();
128        assert_eq!(commit.short_sha(), "1945ab9");
129    }
130
131    #[test]
132    fn test_short_sha_handles_short_input() {
133        let mut commit = sample_commit();
134        commit.sha = "abc".to_string();
135        assert_eq!(commit.short_sha(), "abc");
136    }
137
138    #[test]
139    fn test_is_merge_with_multiple_parents() {
140        let mut commit = sample_commit();
141        commit.parents = vec![
142            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
143            "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
144        ];
145        assert!(commit.is_merge());
146    }
147
148    #[test]
149    fn test_is_merge_with_single_parent() {
150        let commit = sample_commit();
151        assert!(!commit.is_merge());
152    }
153
154    #[test]
155    fn test_is_root_with_no_parents() {
156        let mut commit = sample_commit();
157        commit.parents = vec![];
158        assert!(commit.is_root());
159    }
160
161    #[test]
162    fn test_is_root_with_parents() {
163        let commit = sample_commit();
164        assert!(!commit.is_root());
165    }
166
167    #[test]
168    fn test_subject_multiline() {
169        let commit = sample_commit();
170        assert_eq!(commit.subject(), "feat(skills): add milestone-creator");
171    }
172
173    #[test]
174    fn test_subject_single_line() {
175        let mut commit = sample_commit();
176        commit.message = "Simple message".to_string();
177        assert_eq!(commit.subject(), "Simple message");
178    }
179
180    #[test]
181    fn test_subject_empty_message() {
182        let mut commit = sample_commit();
183        commit.message = String::new();
184        assert_eq!(commit.subject(), "");
185    }
186
187    #[test]
188    fn test_timestamp_iso8601_serialization() {
189        let commit = sample_commit();
190        let json = serde_json::to_string(&commit).expect("serialize");
191        // chrono serializes to RFC 3339/ISO 8601 format
192        assert!(json.contains("2026-01-17"));
193    }
194}
195
196#[cfg(test)]
197mod property_tests {
198    use super::*;
199    use proptest::prelude::*;
200
201    /// Strategy to generate valid 40-character hex SHA strings
202    fn sha_strategy() -> impl Strategy<Value = String> {
203        proptest::string::string_regex("[0-9a-f]{40}")
204            .expect("valid regex")
205            .prop_map(|s| s.to_lowercase())
206    }
207
208    /// Strategy to generate arbitrary Commit values
209    fn commit_strategy() -> impl Strategy<Value = Commit> {
210        (
211            sha_strategy(),
212            ".*",                                            // message
213            "[A-Za-z ]{1,50}",                               // author name
214            "[a-z]+@[a-z]+\\.[a-z]+",                        // author email
215            0i64..2_000_000_000i64,                          // timestamp as unix seconds
216            proptest::collection::vec(sha_strategy(), 0..3), // parents
217        )
218            .prop_map(|(sha, message, author, author_email, ts, parents)| {
219                let timestamp = DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now);
220                Commit {
221                    sha,
222                    message,
223                    author,
224                    author_email,
225                    timestamp,
226                    parents,
227                }
228            })
229    }
230
231    proptest! {
232        /// Property: Any generated Commit should have a valid SHA
233        #[test]
234        fn prop_commit_sha_is_valid(commit in commit_strategy()) {
235            prop_assert!(
236                Commit::is_valid_sha(&commit.sha),
237                "Generated SHA should be valid: {}",
238                commit.sha
239            );
240        }
241
242        /// Property: Round-trip JSON serialization preserves all fields
243        #[test]
244        fn prop_commit_roundtrip_serialization(commit in commit_strategy()) {
245            let json = serde_json::to_string(&commit).expect("serialize");
246            let deserialized: Commit = serde_json::from_str(&json).expect("deserialize");
247            prop_assert_eq!(commit, deserialized);
248        }
249
250        /// Property: short_sha returns at most 7 characters
251        #[test]
252        fn prop_short_sha_length(commit in commit_strategy()) {
253            let short = commit.short_sha();
254            prop_assert!(short.len() <= 7);
255            prop_assert!(!short.is_empty());
256        }
257
258        /// Property: is_merge is true iff parents.len() > 1
259        #[test]
260        fn prop_is_merge_iff_multiple_parents(commit in commit_strategy()) {
261            prop_assert_eq!(commit.is_merge(), commit.parents.len() > 1);
262        }
263
264        /// Property: is_root is true iff parents is empty
265        #[test]
266        fn prop_is_root_iff_no_parents(commit in commit_strategy()) {
267            prop_assert_eq!(commit.is_root(), commit.parents.is_empty());
268        }
269
270        /// Property: subject is always a substring of message
271        #[test]
272        fn prop_subject_is_prefix_of_message(commit in commit_strategy()) {
273            let subject = commit.subject();
274            prop_assert!(
275                commit.message.starts_with(subject),
276                "Subject '{}' should be prefix of message '{}'",
277                subject,
278                commit.message
279            );
280        }
281
282        /// Property: All parent SHAs should be valid
283        #[test]
284        fn prop_all_parent_shas_valid(commit in commit_strategy()) {
285            for parent in &commit.parents {
286                prop_assert!(
287                    Commit::is_valid_sha(parent),
288                    "Parent SHA should be valid: {}",
289                    parent
290                );
291            }
292        }
293
294        /// Property: is_valid_sha accepts only 40-char lowercase hex
295        #[test]
296        fn prop_valid_sha_format(sha in sha_strategy()) {
297            prop_assert!(Commit::is_valid_sha(&sha));
298            prop_assert_eq!(sha.len(), 40);
299            prop_assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
300        }
301
302        /// Property: is_valid_sha rejects strings of wrong length
303        #[test]
304        fn prop_invalid_sha_wrong_length(
305            prefix in "[0-9a-f]{0,39}",
306            suffix in "[0-9a-f]{0,10}"
307        ) {
308            let combined = format!("{}{}", prefix, suffix);
309            if combined.len() != 40 {
310                prop_assert!(!Commit::is_valid_sha(&combined));
311            }
312        }
313    }
314}