1use std::path::Path;
4use std::process::Command;
5
6#[derive(Debug, Clone)]
7pub struct CommitInfo {
8 pub sha: String,
9 pub short_sha: String,
10 pub author_name: String,
11}
12
13#[derive(Debug, Clone)]
14pub struct GitHubUserInfo {
15 pub login: String,
16 pub is_first_contribution: bool,
17}
18
19pub fn get_commit_hash_for_path(repo_root: &Path, file_path: &Path) -> Option<String> {
21 let output = Command::new("git")
22 .current_dir(repo_root)
23 .args([
24 "log",
25 "-1",
26 "--format=%H",
27 "--",
28 &file_path.to_string_lossy(),
29 ])
30 .output()
31 .ok()?;
32
33 if output.status.success() {
34 let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
35 if !hash.is_empty() { Some(hash) } else { None }
36 } else {
37 None
38 }
39}
40
41pub fn detect_github_repo_slug(repo_root: &Path) -> Option<String> {
43 detect_github_repo_slug_with_config(repo_root, None)
44}
45
46pub fn detect_github_repo_slug_with_config(
48 repo_root: &Path,
49 config_repo: Option<&str>,
50) -> Option<String> {
51 if let Some(repo) = config_repo {
53 return Some(repo.to_string());
54 }
55
56 let output = Command::new("git")
58 .current_dir(repo_root)
59 .args(["remote", "get-url", "origin"])
60 .output()
61 .ok()?;
62
63 if !output.status.success() {
64 return None;
65 }
66
67 let binding = String::from_utf8_lossy(&output.stdout);
68 let url = binding.trim();
69
70 parse_github_url(url)
72}
73
74fn parse_github_url(url: &str) -> Option<String> {
76 if let Some(rest) = url.strip_prefix("https://github.com/") {
78 let without_git = rest.strip_suffix(".git").unwrap_or(rest);
79 if without_git.split('/').count() >= 2 {
80 return Some(without_git.to_string());
81 }
82 }
83
84 if let Some(rest) = url.strip_prefix("git@github.com:") {
86 let without_git = rest.strip_suffix(".git").unwrap_or(rest);
87 if without_git.split('/').count() >= 2 {
88 return Some(without_git.to_string());
89 }
90 }
91
92 None
93}
94
95pub fn enrich_changeset_message(
97 message: &str,
98 commit_hash: &str,
99 workspace: &Path,
100 repo_slug: Option<&str>,
101 github_token: Option<&str>,
102 show_commit_hash: bool,
103 show_acknowledgments: bool,
104) -> String {
105 let rt = tokio::runtime::Builder::new_current_thread()
107 .enable_all()
108 .build()
109 .unwrap();
110 rt.block_on(enrich_changeset_message_async(
111 message,
112 commit_hash,
113 workspace,
114 repo_slug,
115 github_token,
116 show_commit_hash,
117 show_acknowledgments,
118 ))
119}
120
121async fn enrich_changeset_message_async(
123 message: &str,
124 commit_hash: &str,
125 workspace: &Path,
126 repo_slug: Option<&str>,
127 github_token: Option<&str>,
128 show_commit_hash: bool,
129 show_acknowledgments: bool,
130) -> String {
131 let commit = get_commit_info_for_hash(workspace, commit_hash);
132
133 let commit_prefix = if show_commit_hash {
134 build_commit_prefix(&commit, repo_slug)
135 } else {
136 String::new()
137 };
138
139 let acknowledgment_suffix = if show_acknowledgments {
140 build_acknowledgment_suffix(&commit, repo_slug, github_token).await
141 } else {
142 String::new()
143 };
144
145 format_enriched_message(message, &commit_prefix, &acknowledgment_suffix)
146}
147
148fn get_commit_info_for_hash(repo_root: &Path, commit_hash: &str) -> Option<CommitInfo> {
150 let format_arg = "--format=%H\x1f%h\x1f%an";
152 let output = Command::new("git")
153 .current_dir(repo_root)
154 .args(["show", "--no-patch", format_arg, commit_hash])
155 .output()
156 .ok()?;
157
158 if !output.status.success() {
159 return None;
160 }
161
162 let stdout = String::from_utf8_lossy(&output.stdout);
163 let parts: Vec<&str> = stdout.trim().split('\x1f').collect();
164 if parts.len() != 3 {
165 return None;
166 }
167
168 Some(CommitInfo {
169 sha: parts[0].to_string(),
170 short_sha: parts[1].to_string(),
171 author_name: parts[2].to_string(),
172 })
173}
174
175fn build_commit_prefix(commit: &Option<CommitInfo>, repo_slug: Option<&str>) -> String {
177 if let Some(commit) = commit {
178 if let Some(slug) = repo_slug {
179 format!(
180 "[{}](https://github.com/{}/commit/{}) ",
181 commit.short_sha, slug, commit.sha
182 )
183 } else {
184 format!("{} ", commit.short_sha)
185 }
186 } else {
187 String::new()
188 }
189}
190
191async fn build_acknowledgment_suffix(
193 commit: &Option<CommitInfo>,
194 repo_slug: Option<&str>,
195 github_token: Option<&str>,
196) -> String {
197 let Some(commit) = commit else {
198 return String::new();
199 };
200
201 if let (Some(slug), Some(token)) = (repo_slug, github_token)
203 && let Some(github_user) = get_github_user_for_commit(slug, &commit.sha, token).await
204 {
205 if github_user.is_first_contribution {
206 return format!(
207 " — Thanks @{} for your first contribution 🎉!",
208 github_user.login
209 );
210 } else {
211 return format!(" — Thanks @{}!", github_user.login);
212 }
213 }
214
215 format!(" — Thanks {}!", commit.author_name)
217}
218
219fn format_enriched_message(
221 message: &str,
222 commit_prefix: &str,
223 acknowledgment_suffix: &str,
224) -> String {
225 format!("{}{}{}", commit_prefix, message, acknowledgment_suffix)
226}
227
228async fn get_github_user_for_commit(
230 _repo_slug: &str,
231 _commit_sha: &str,
232 _token: &str,
233) -> Option<GitHubUserInfo> {
234 None
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn parse_github_url_https() {
248 assert_eq!(
249 parse_github_url("https://github.com/owner/repo.git"),
250 Some("owner/repo".to_string())
251 );
252 assert_eq!(
253 parse_github_url("https://github.com/owner/repo"),
254 Some("owner/repo".to_string())
255 );
256 }
257
258 #[test]
259 fn parse_github_url_ssh() {
260 assert_eq!(
261 parse_github_url("git@github.com:owner/repo.git"),
262 Some("owner/repo".to_string())
263 );
264 }
265
266 #[test]
267 fn parse_github_url_invalid() {
268 assert_eq!(parse_github_url("https://gitlab.com/owner/repo.git"), None);
269 assert_eq!(parse_github_url("not-a-url"), None);
270 }
271
272 #[test]
273 fn build_commit_prefix_with_repo() {
274 let commit = Some(CommitInfo {
275 sha: "abcd1234".to_string(),
276 short_sha: "abcd".to_string(),
277 author_name: "Author".to_string(),
278 });
279
280 let prefix = build_commit_prefix(&commit, Some("owner/repo"));
281 assert_eq!(
282 prefix,
283 "[abcd](https://github.com/owner/repo/commit/abcd1234) "
284 );
285 }
286
287 #[test]
288 fn build_commit_prefix_without_repo() {
289 let commit = Some(CommitInfo {
290 sha: "abcd1234".to_string(),
291 short_sha: "abcd".to_string(),
292 author_name: "Author".to_string(),
293 });
294
295 let prefix = build_commit_prefix(&commit, None);
296 assert_eq!(prefix, "abcd ");
297 }
298
299 #[test]
300 fn format_enriched_message_complete() {
301 let message =
302 format_enriched_message("feat: add new feature", "[abcd](link) ", " — Thanks @user!");
303 assert_eq!(
304 message,
305 "[abcd](link) feat: add new feature — Thanks @user!"
306 );
307 }
308
309 #[test]
310 fn enrich_changeset_message_integration() {
311 use std::fs;
312 use tempfile::TempDir;
313
314 let temp_dir = TempDir::new().unwrap();
315 let repo_path = temp_dir.path();
316
317 std::process::Command::new("git")
319 .arg("init")
320 .current_dir(repo_path)
321 .output()
322 .unwrap();
323
324 std::process::Command::new("git")
326 .args(["config", "user.name", "Test User"])
327 .current_dir(repo_path)
328 .output()
329 .unwrap();
330
331 std::process::Command::new("git")
332 .args(["config", "user.email", "test@example.com"])
333 .current_dir(repo_path)
334 .output()
335 .unwrap();
336
337 let test_file = repo_path.join("test.md");
339 fs::write(&test_file, "initial content").unwrap();
340
341 std::process::Command::new("git")
342 .args(["add", "test.md"])
343 .current_dir(repo_path)
344 .output()
345 .unwrap();
346
347 std::process::Command::new("git")
348 .args(["commit", "-m", "initial commit"])
349 .current_dir(repo_path)
350 .output()
351 .unwrap();
352
353 let commit_hash = get_commit_hash_for_path(repo_path, &test_file)
355 .expect("Should find commit hash for test file");
356
357 let enriched = enrich_changeset_message(
359 "fix: resolve critical bug",
360 &commit_hash,
361 repo_path,
362 Some("owner/repo"),
363 None, true, true, );
367
368 assert!(
370 enriched.contains(&commit_hash[..8]),
371 "Should contain short commit hash"
372 );
373 assert!(
374 enriched.contains("Thanks Test User!"),
375 "Should contain author thanks"
376 );
377 assert!(
378 enriched.contains("fix: resolve critical bug"),
379 "Should contain original message"
380 );
381
382 let plain = enrich_changeset_message(
384 "fix: resolve critical bug",
385 &commit_hash,
386 repo_path,
387 Some("owner/repo"),
388 None,
389 false, false, );
392
393 assert_eq!(
394 plain, "fix: resolve critical bug",
395 "Should be unchanged when features disabled"
396 );
397 }
398}