1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct Commit {
12 pub sha: String,
14 pub message: String,
16 pub author: String,
18 pub author_email: String,
20 pub timestamp: DateTime<Utc>,
22 pub parents: Vec<String>,
24}
25
26impl Commit {
27 #[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 #[must_use]
35 pub fn short_sha(&self) -> &str {
36 &self.sha[..7.min(self.sha.len())]
37 }
38
39 #[must_use]
41 pub fn is_merge(&self) -> bool {
42 self.parents.len() > 1
43 }
44
45 #[must_use]
47 pub fn is_root(&self) -> bool {
48 self.parents.is_empty()
49 }
50
51 #[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 assert!(!Commit::is_valid_sha("1945ab9"));
113 assert!(!Commit::is_valid_sha(
115 "1945ab9c752534e733c38ba0109dc3b741f0a6eb0"
116 ));
117 assert!(!Commit::is_valid_sha(
119 "1945ab9c752534e733c38ba0109dc3b741f0a6eg"
120 ));
121 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 assert!(json.contains("2026-01-17"));
193 }
194}
195
196#[cfg(test)]
197mod property_tests {
198 use super::*;
199 use proptest::prelude::*;
200
201 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 fn commit_strategy() -> impl Strategy<Value = Commit> {
210 (
211 sha_strategy(),
212 ".*", "[A-Za-z ]{1,50}", "[a-z]+@[a-z]+\\.[a-z]+", 0i64..2_000_000_000i64, proptest::collection::vec(sha_strategy(), 0..3), )
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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}