1use anyhow::{bail, Context, Result};
2use glob::Pattern;
3use std::fs;
4use std::path::Path;
5use std::process::{Command, Stdio};
6
7pub fn get_staged_diff() -> Result<String> {
9 let output = Command::new("git")
10 .args(["diff", "--staged"])
11 .output()
12 .context("Failed to run git diff --staged")?;
13
14 if !output.status.success() {
15 let stderr = String::from_utf8_lossy(&output.stderr);
16 bail!("git diff --staged failed: {stderr}");
17 }
18
19 let diff = String::from_utf8_lossy(&output.stdout).to_string();
20
21 if diff.trim().is_empty() {
22 bail!(
23 "No staged changes found. Stage files with {} first.",
24 colored::Colorize::yellow("git add <files>")
25 );
26 }
27
28 Ok(diff)
29}
30
31pub fn list_staged_files() -> Result<Vec<String>> {
33 let output = Command::new("git")
34 .args(["diff", "--staged", "--name-only"])
35 .output()
36 .context("Failed to run git diff --staged --name-only")?;
37
38 if !output.status.success() {
39 let stderr = String::from_utf8_lossy(&output.stderr);
40 bail!("git diff --staged --name-only failed: {stderr}");
41 }
42
43 let files = String::from_utf8_lossy(&output.stdout)
44 .lines()
45 .map(str::trim)
46 .filter(|line| !line.is_empty())
47 .map(ToString::to_string)
48 .collect::<Vec<_>>();
49
50 Ok(files)
51}
52
53pub fn find_repo_root() -> Result<String> {
55 let output = Command::new("git")
56 .args(["rev-parse", "--show-toplevel"])
57 .output()
58 .context("Failed to run git rev-parse")?;
59
60 if !output.status.success() {
61 bail!("Not in a git repository");
62 }
63
64 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
65}
66
67pub fn run_commit(message: &str, extra_args: &[String], suppress_output: bool) -> Result<()> {
69 let mut cmd = Command::new("git");
70 cmd.args(["commit", "-m", message]);
71 cmd.args(extra_args);
72 configure_stdio(&mut cmd, suppress_output);
73 let status = cmd.status().context("Failed to run git commit")?;
74
75 if !status.success() {
76 bail!("git commit exited with status {status}");
77 }
78
79 Ok(())
80}
81
82pub fn run_push(suppress_output: bool) -> Result<()> {
84 let mut cmd = Command::new("git");
85 cmd.arg("push");
86 configure_stdio(&mut cmd, suppress_output);
87
88 let status = cmd.status().context("Failed to run git push")?;
89 if !status.success() {
90 bail!("git push exited with status {status}");
91 }
92
93 Ok(())
94}
95
96pub fn get_latest_tag() -> Result<Option<String>> {
98 let output = Command::new("git")
99 .args(["tag", "--sort=-version:refname"])
100 .output()
101 .context("Failed to run git tag --sort=-version:refname")?;
102
103 if !output.status.success() {
104 let stderr = String::from_utf8_lossy(&output.stderr);
105 bail!("git tag --sort=-version:refname failed: {stderr}");
106 }
107
108 let latest = String::from_utf8_lossy(&output.stdout)
109 .lines()
110 .map(str::trim)
111 .find(|line| !line.is_empty())
112 .map(ToString::to_string);
113
114 Ok(latest)
115}
116
117pub fn compute_next_minor_tag(latest: Option<&str>) -> Result<String> {
119 let Some(latest_tag) = latest else {
120 return Ok("0.1.0".to_string());
121 };
122
123 let (major, minor, _patch) = parse_semver_tag(latest_tag)?;
124 Ok(format!("{major}.{}.0", minor + 1))
125}
126
127pub fn create_tag(tag_name: &str, suppress_output: bool) -> Result<()> {
129 let mut cmd = Command::new("git");
130 cmd.args(["tag", tag_name]);
131 configure_stdio(&mut cmd, suppress_output);
132 let status = cmd.status().context("Failed to run git tag")?;
133
134 if !status.success() {
135 bail!("git tag exited with status {status}");
136 }
137
138 Ok(())
139}
140
141pub fn is_head_pushed() -> Result<bool> {
143 if !has_upstream_branch()? {
144 return Ok(false);
145 }
146
147 let output = Command::new("git")
148 .args(["branch", "-r", "--contains", "HEAD"])
149 .output()
150 .context("Failed to determine whether HEAD is pushed")?;
151
152 if !output.status.success() {
153 let stderr = String::from_utf8_lossy(&output.stderr);
154 bail!("git branch -r --contains HEAD failed: {stderr}");
155 }
156
157 let stdout = String::from_utf8_lossy(&output.stdout);
158 let pushed = stdout
159 .lines()
160 .map(str::trim)
161 .any(|line| !line.is_empty() && !line.contains("->"));
162 Ok(pushed)
163}
164
165pub fn head_is_merge_commit() -> Result<bool> {
167 ensure_head_exists()?;
168
169 let output = Command::new("git")
170 .args(["rev-list", "--parents", "-n", "1", "HEAD"])
171 .output()
172 .context("Failed to inspect latest commit parents")?;
173
174 if !output.status.success() {
175 let stderr = String::from_utf8_lossy(&output.stderr);
176 bail!("git rev-list --parents -n 1 HEAD failed: {stderr}");
177 }
178
179 let parent_count = String::from_utf8_lossy(&output.stdout)
180 .split_whitespace()
181 .count()
182 .saturating_sub(1);
183 Ok(parent_count > 1)
184}
185
186pub fn undo_last_commit_soft(suppress_output: bool) -> Result<()> {
188 ensure_head_exists()?;
189
190 let mut cmd = Command::new("git");
191 cmd.args(["reset", "--soft", "HEAD~1"]);
192 configure_stdio(&mut cmd, suppress_output);
193
194 let status = cmd
195 .status()
196 .context("Failed to run git reset --soft HEAD~1")?;
197 if !status.success() {
198 bail!("git reset --soft HEAD~1 exited with status {status}");
199 }
200 Ok(())
201}
202
203pub fn has_upstream_branch() -> Result<bool> {
204 let status = Command::new("git")
205 .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
206 .stdout(Stdio::null())
207 .stderr(Stdio::null())
208 .status()
209 .context("Failed to detect upstream branch")?;
210 Ok(status.success())
211}
212
213pub fn ensure_head_exists() -> Result<()> {
214 let status = Command::new("git")
215 .args(["rev-parse", "--verify", "HEAD"])
216 .stdout(Stdio::null())
217 .stderr(Stdio::null())
218 .status()
219 .context("Failed to run git rev-parse --verify HEAD")?;
220
221 if !status.success() {
222 bail!("No commits found in this repository.");
223 }
224 Ok(())
225}
226
227fn configure_stdio(cmd: &mut Command, suppress_output: bool) {
228 if suppress_output {
229 cmd.stdout(Stdio::null()).stderr(Stdio::null());
230 }
231}
232
233pub fn ensure_commit_exists(commit: &str) -> Result<()> {
234 let status = Command::new("git")
235 .args(["rev-parse", "--verify", &format!("{commit}^{{commit}}")])
236 .stdout(Stdio::null())
237 .stderr(Stdio::null())
238 .status()
239 .with_context(|| format!("Failed to verify commit reference {commit}"))?;
240
241 if !status.success() {
242 bail!("Commit reference not found: {commit}");
243 }
244 Ok(())
245}
246
247pub fn get_commit_diff(commit: &str) -> Result<String> {
248 ensure_commit_exists(commit)?;
249 let output = Command::new("git")
250 .args(["show", "--format=", "--no-color", commit])
251 .output()
252 .with_context(|| format!("Failed to run git show for {commit}"))?;
253
254 if !output.status.success() {
255 let stderr = String::from_utf8_lossy(&output.stderr);
256 bail!("git show failed for {commit}: {stderr}");
257 }
258
259 let diff = String::from_utf8_lossy(&output.stdout).to_string();
260 if diff.trim().is_empty() {
261 bail!("Selected commit has no diff to analyze: {commit}");
262 }
263 Ok(diff)
264}
265
266pub fn get_range_diff(older: &str, newer: &str) -> Result<String> {
267 ensure_commit_exists(older)?;
268 ensure_commit_exists(newer)?;
269
270 let output = Command::new("git")
271 .args(["diff", "--no-color", older, newer])
272 .output()
273 .with_context(|| format!("Failed to run git diff {older} {newer}"))?;
274
275 if !output.status.success() {
276 let stderr = String::from_utf8_lossy(&output.stderr);
277 bail!("git diff failed for {older}..{newer}: {stderr}");
278 }
279
280 let diff = String::from_utf8_lossy(&output.stdout).to_string();
281 if diff.trim().is_empty() {
282 bail!("No diff found for range {older}..{newer}");
283 }
284 Ok(diff)
285}
286
287pub fn is_head_commit(commit: &str) -> Result<bool> {
288 ensure_commit_exists(commit)?;
289 Ok(resolve_commit("HEAD")? == resolve_commit(commit)?)
290}
291
292pub fn commit_is_merge(commit: &str) -> Result<bool> {
293 ensure_commit_exists(commit)?;
294 let output = Command::new("git")
295 .args(["rev-list", "--parents", "-n", "1", commit])
296 .output()
297 .with_context(|| format!("Failed to inspect parents for {commit}"))?;
298
299 if !output.status.success() {
300 let stderr = String::from_utf8_lossy(&output.stderr);
301 bail!("git rev-list failed for {commit}: {stderr}");
302 }
303
304 let parent_count = String::from_utf8_lossy(&output.stdout)
305 .split_whitespace()
306 .count()
307 .saturating_sub(1);
308 Ok(parent_count > 1)
309}
310
311pub fn commit_is_pushed(commit: &str) -> Result<bool> {
312 ensure_commit_exists(commit)?;
313 if !has_upstream_branch()? {
314 return Ok(false);
315 }
316
317 let output = Command::new("git")
318 .args(["branch", "-r", "--contains", commit])
319 .output()
320 .with_context(|| format!("Failed to determine whether {commit} is pushed"))?;
321
322 if !output.status.success() {
323 let stderr = String::from_utf8_lossy(&output.stderr);
324 bail!("git branch -r --contains {commit} failed: {stderr}");
325 }
326
327 let stdout = String::from_utf8_lossy(&output.stdout);
328 Ok(stdout
329 .lines()
330 .map(str::trim)
331 .any(|line| !line.is_empty() && !line.contains("->")))
332}
333
334pub fn rewrite_commit_message(target: &str, message: &str, suppress_output: bool) -> Result<()> {
335 ensure_commit_exists(target)?;
336
337 if is_head_commit(target)? {
338 let mut cmd = Command::new("git");
339 cmd.args(["commit", "--amend", "-m", message]);
340 configure_stdio(&mut cmd, suppress_output);
341 let status = cmd.status().context("Failed to run git commit --amend")?;
342 if !status.success() {
343 bail!("git commit --amend exited with status {status}");
344 }
345 return Ok(());
346 }
347
348 if commit_is_merge(target)? {
349 bail!("Altering non-HEAD merge commits is not supported.");
350 }
351
352 ensure_ancestor_of_head(target)?;
353 reword_non_head_commit(target, message, suppress_output)
354}
355
356fn resolve_commit(commit: &str) -> Result<String> {
357 let output = Command::new("git")
358 .args(["rev-parse", "--verify", &format!("{commit}^{{commit}}")])
359 .output()
360 .with_context(|| format!("Failed to resolve commit {commit}"))?;
361
362 if !output.status.success() {
363 let stderr = String::from_utf8_lossy(&output.stderr);
364 bail!("Failed to resolve commit {commit}: {stderr}");
365 }
366
367 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
368}
369
370pub fn ensure_ancestor_of_head(commit: &str) -> Result<()> {
371 let status = Command::new("git")
372 .args(["merge-base", "--is-ancestor", commit, "HEAD"])
373 .stdout(Stdio::null())
374 .stderr(Stdio::null())
375 .status()
376 .with_context(|| format!("Failed to check whether {commit} is an ancestor of HEAD"))?;
377
378 if !status.success() {
379 bail!("Target commit must be on the current branch and reachable from HEAD.");
380 }
381 Ok(())
382}
383
384fn reword_non_head_commit(target: &str, message: &str, suppress_output: bool) -> Result<()> {
385 let parent = format!("{target}^");
386 let temp = std::env::temp_dir();
387 let sequence_editor = temp.join(format!("cgen-seq-editor-{}.sh", std::process::id()));
388 let message_editor = temp.join(format!("cgen-msg-editor-{}.sh", std::process::id()));
389
390 write_sequence_editor_script(&sequence_editor)?;
391 write_message_editor_script(&message_editor)?;
392
393 let mut cmd = Command::new("git");
394 cmd.args(["rebase", "-i", &parent]);
395 cmd.env("GIT_SEQUENCE_EDITOR", script_command(&sequence_editor));
396 cmd.env("GIT_EDITOR", script_command(&message_editor));
397 cmd.env("CGEN_NEW_MESSAGE", message);
398 configure_stdio(&mut cmd, suppress_output);
399
400 let status = cmd.status().context("Failed to run git rebase -i")?;
401
402 let _ = fs::remove_file(&sequence_editor);
403 let _ = fs::remove_file(&message_editor);
404
405 if !status.success() {
406 bail!(
407 "Rewriting commit message failed during rebase. Resolve conflicts and run `git rebase --abort` if needed."
408 );
409 }
410 Ok(())
411}
412
413fn write_sequence_editor_script(path: &Path) -> Result<()> {
414 let script = r#"#!/bin/sh
415set -e
416todo="$1"
417tmp="${todo}.cgen"
418first=1
419
420while IFS= read -r line; do
421 if [ "$first" -eq 1 ] && printf '%s\n' "$line" | grep -q '^pick '; then
422 printf '%s\n' "$line" | sed 's/^pick /reword /' >> "$tmp"
423 first=0
424 else
425 printf '%s\n' "$line" >> "$tmp"
426 fi
427done < "$todo"
428
429mv "$tmp" "$todo"
430"#;
431 fs::write(path, script).with_context(|| format!("Failed to write {:?}", path))?;
432 make_executable(path)?;
433 Ok(())
434}
435
436fn write_message_editor_script(path: &Path) -> Result<()> {
437 let script = r#"#!/bin/sh
438set -e
439msg_file="$1"
440printf '%s\n' "$CGEN_NEW_MESSAGE" > "$msg_file"
441"#;
442 fs::write(path, script).with_context(|| format!("Failed to write {:?}", path))?;
443 make_executable(path)?;
444 Ok(())
445}
446
447fn make_executable(path: &Path) -> Result<()> {
448 #[cfg(not(unix))]
449 let _ = path;
450
451 #[cfg(unix)]
452 {
453 use std::os::unix::fs::PermissionsExt;
454 let mut perms = fs::metadata(path)?.permissions();
455 perms.set_mode(0o700);
456 fs::set_permissions(path, perms)?;
457 }
458 Ok(())
459}
460
461fn script_command(path: &Path) -> String {
462 path.to_string_lossy().replace('\\', "/")
463}
464
465fn parse_semver_tag(tag: &str) -> Result<(u64, u64, u64)> {
466 let parts: Vec<&str> = tag.trim().split('.').collect();
467 if parts.len() != 3 {
468 bail!("Latest tag '{tag}' is not valid semantic versioning (expected MAJOR.MINOR.PATCH).");
469 }
470
471 let major = parts[0]
472 .parse::<u64>()
473 .with_context(|| format!("Latest tag '{tag}' is not valid semantic versioning."))?;
474 let minor = parts[1]
475 .parse::<u64>()
476 .with_context(|| format!("Latest tag '{tag}' is not valid semantic versioning."))?;
477 let patch = parts[2]
478 .parse::<u64>()
479 .with_context(|| format!("Latest tag '{tag}' is not valid semantic versioning."))?;
480
481 Ok((major, minor, patch))
482}
483
484pub fn filter_diff_by_globs(diff: &str, exclude_patterns: &[String]) -> String {
487 if exclude_patterns.is_empty() {
488 return diff.to_string();
489 }
490
491 let patterns: Vec<Pattern> = exclude_patterns
492 .iter()
493 .filter_map(|p| Pattern::new(p).ok())
494 .collect();
495
496 if patterns.is_empty() {
497 return diff.to_string();
498 }
499
500 let mut result = String::new();
501 let mut include_current = true;
502
503 for line in diff.lines() {
504 if line.starts_with("diff --git ") {
505 let file_path = line
507 .strip_prefix("diff --git a/")
508 .and_then(|s| s.split(" b/").next())
509 .unwrap_or("");
510
511 let filename = std::path::Path::new(file_path)
513 .file_name()
514 .and_then(|n| n.to_str())
515 .unwrap_or(file_path);
516
517 include_current = !patterns.iter().any(|p| p.matches(filename));
518 }
519
520 if include_current {
521 result.push_str(line);
522 result.push('\n');
523 }
524 }
525
526 result
527}
528
529pub fn get_staged_diff_filtered(exclude_patterns: &[String]) -> Result<String> {
532 let diff = get_staged_diff()?;
533 Ok(filter_diff_by_globs(&diff, exclude_patterns))
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539
540 #[test]
541 fn test_parse_semver_tag_valid() {
542 let (major, minor, patch) = parse_semver_tag("1.2.3").unwrap();
543 assert_eq!((major, minor, patch), (1, 2, 3));
544 }
545
546 #[test]
547 fn test_parse_semver_tag_zeros() {
548 let (major, minor, patch) = parse_semver_tag("0.0.0").unwrap();
549 assert_eq!((major, minor, patch), (0, 0, 0));
550 }
551
552 #[test]
553 fn test_parse_semver_tag_large_numbers() {
554 let (major, minor, patch) = parse_semver_tag("100.200.300").unwrap();
555 assert_eq!((major, minor, patch), (100, 200, 300));
556 }
557
558 #[test]
559 fn test_parse_semver_tag_invalid_format_two_parts() {
560 let result = parse_semver_tag("1.2");
561 assert!(result.is_err());
562 assert!(result.unwrap_err().to_string().contains("not valid semantic"));
563 }
564
565 #[test]
566 fn test_parse_semver_tag_invalid_format_four_parts() {
567 let result = parse_semver_tag("1.2.3.4");
568 assert!(result.is_err());
569 }
570
571 #[test]
572 fn test_parse_semver_tag_non_numeric_major() {
573 let result = parse_semver_tag("a.2.3");
574 assert!(result.is_err());
575 }
576
577 #[test]
578 fn test_parse_semver_tag_non_numeric_minor() {
579 let result = parse_semver_tag("1.b.3");
580 assert!(result.is_err());
581 }
582
583 #[test]
584 fn test_parse_semver_tag_non_numeric_patch() {
585 let result = parse_semver_tag("1.2.c");
586 assert!(result.is_err());
587 }
588
589 #[test]
590 fn test_parse_semver_tag_with_v_prefix() {
591 let result = parse_semver_tag("v1.2.3");
593 assert!(result.is_err());
594 }
595
596 #[test]
597 fn test_parse_semver_tag_with_whitespace() {
598 let (major, minor, patch) = parse_semver_tag(" 1.2.3 ").unwrap();
600 assert_eq!((major, minor, patch), (1, 2, 3));
601 }
602
603 #[test]
604 fn test_script_command_unix_path() {
605 let path = std::path::Path::new("/tmp/script.sh");
606 let result = script_command(path);
607 assert_eq!(result, "/tmp/script.sh");
608 }
609
610 #[test]
611 fn test_script_command_windows_path() {
612 let path = std::path::Path::new("C:\\Users\\test\\script.sh");
613 let result = script_command(path);
614 assert!(result.contains('/'));
616 assert!(!result.contains('\\'));
617 }
618
619 #[test]
620 fn test_configure_stdio_suppress() {
621 let mut cmd = Command::new("echo");
622 configure_stdio(&mut cmd, true);
623 }
625
626 #[test]
627 fn test_configure_stdio_no_suppress() {
628 let mut cmd = Command::new("echo");
629 configure_stdio(&mut cmd, false);
630 }
632
633 #[test]
634 fn test_filter_diff_all_invalid_patterns() {
635 let diff = "diff --git a/test.rs b/test.rs\n+code\n";
636 let patterns = vec!["[invalid".to_string(), "[also[bad".to_string()];
638 let filtered = filter_diff_by_globs(diff, &patterns);
639 assert_eq!(filtered, diff);
640 }
641
642 #[test]
643 fn test_filter_diff_empty_diff() {
644 let patterns = vec!["*.json".to_string()];
645 let filtered = filter_diff_by_globs("", &patterns);
646 assert_eq!(filtered, "");
647 }
648
649 #[test]
650 fn test_filter_diff_no_diff_header() {
651 let content = "just some random text\nwithout diff headers";
653 let patterns = vec!["*.json".to_string()];
654 let filtered = filter_diff_by_globs(content, &patterns);
655 assert!(filtered.contains("random text"));
657 }
658
659 #[test]
660 fn test_filter_diff_malformed_header() {
661 let diff = "diff --git \n+something\n";
663 let patterns = vec!["*.json".to_string()];
664 let filtered = filter_diff_by_globs(diff, &patterns);
665 assert!(filtered.contains("something"));
667 }
668
669 #[test]
670 fn test_compute_next_minor_tag_increment() {
671 let result = compute_next_minor_tag(Some("2.5.9")).unwrap();
672 assert_eq!(result, "2.6.0");
673 }
674
675 #[test]
676 fn test_compute_next_minor_tag_none() {
677 let result = compute_next_minor_tag(None).unwrap();
678 assert_eq!(result, "0.1.0");
679 }
680
681 #[test]
682 fn test_make_executable_windows() {
683 let temp_dir = std::env::temp_dir();
685 let test_file = temp_dir.join("test_make_executable.sh");
686 std::fs::write(&test_file, "#!/bin/sh\necho test").unwrap();
687 let result = make_executable(&test_file);
688 assert!(result.is_ok());
689 let _ = std::fs::remove_file(&test_file);
690 }
691
692 #[test]
693 fn test_write_sequence_editor_script() {
694 let temp_dir = std::env::temp_dir();
695 let script_path = temp_dir.join("test_seq_editor.sh");
696 let result = write_sequence_editor_script(&script_path);
697 assert!(result.is_ok());
698 let content = std::fs::read_to_string(&script_path).unwrap();
699 assert!(content.contains("#!/bin/sh"));
700 assert!(content.contains("reword"));
701 let _ = std::fs::remove_file(&script_path);
702 }
703
704 #[test]
705 fn test_write_message_editor_script() {
706 let temp_dir = std::env::temp_dir();
707 let script_path = temp_dir.join("test_msg_editor.sh");
708 let result = write_message_editor_script(&script_path);
709 assert!(result.is_ok());
710 let content = std::fs::read_to_string(&script_path).unwrap();
711 assert!(content.contains("#!/bin/sh"));
712 assert!(content.contains("CGEN_NEW_MESSAGE"));
713 let _ = std::fs::remove_file(&script_path);
714 }
715
716 #[test]
717 fn test_filter_diff_single_file_excluded() {
718 let diff = "diff --git a/config.json b/config.json\n+{}\n";
719 let patterns = vec!["*.json".to_string()];
720 let filtered = filter_diff_by_globs(diff, &patterns);
721 assert!(filtered.is_empty() || !filtered.contains("config.json"));
722 }
723
724 #[test]
725 fn test_filter_diff_preserves_context_lines() {
726 let diff = r#"diff --git a/main.rs b/main.rs
727--- a/main.rs
728+++ b/main.rs
729@@ -1,5 +1,6 @@
730 fn main() {
731+ println!("new");
732 old_code();
733 }
734"#;
735 let patterns = vec!["*.json".to_string()]; let filtered = filter_diff_by_globs(diff, &patterns);
737 assert!(filtered.contains("fn main()"));
738 assert!(filtered.contains("println!"));
739 assert!(filtered.contains("old_code"));
740 }
741
742 #[test]
743 fn test_filter_diff_consecutive_files() {
744 let diff = r#"diff --git a/a.json b/a.json
745+first
746diff --git a/b.json b/b.json
747+second
748diff --git a/c.rs b/c.rs
749+third
750"#;
751 let patterns = vec!["*.json".to_string()];
752 let filtered = filter_diff_by_globs(diff, &patterns);
753 assert!(!filtered.contains("first"));
754 assert!(!filtered.contains("second"));
755 assert!(filtered.contains("third"));
756 }
757
758 #[test]
759 fn test_parse_semver_tag_empty() {
760 let result = parse_semver_tag("");
761 assert!(result.is_err());
762 }
763
764 #[test]
765 fn test_parse_semver_tag_single_number() {
766 let result = parse_semver_tag("1");
767 assert!(result.is_err());
768 }
769
770 #[test]
771 fn test_script_command_empty_path() {
772 let path = std::path::Path::new("");
773 let result = script_command(path);
774 assert_eq!(result, "");
775 }
776
777 #[test]
778 fn test_script_command_relative_path() {
779 let path = std::path::Path::new("scripts/test.sh");
780 let result = script_command(path);
781 assert_eq!(result, "scripts/test.sh");
782 }
783
784 #[test]
785 fn test_filter_diff_mixed_content_types() {
786 let diff = r#"diff --git a/readme.md b/readme.md
787--- a/readme.md
788+++ b/readme.md
789@@ -1 +1,2 @@
790+Documentation
791diff --git a/package-lock.json b/package-lock.json
792--- a/package-lock.json
793+++ b/package-lock.json
794@@ -1 +1,2 @@
795+{"deps": true}
796diff --git a/src/app.ts b/src/app.ts
797--- a/src/app.ts
798+++ b/src/app.ts
799@@ -1 +1,2 @@
800+// TypeScript code
801"#;
802 let patterns = vec!["*.json".to_string()];
803 let filtered = filter_diff_by_globs(diff, &patterns);
804
805 assert!(filtered.contains("readme.md"));
806 assert!(filtered.contains("Documentation"));
807 assert!(!filtered.contains("package-lock.json"));
808 assert!(filtered.contains("src/app.ts"));
809 assert!(filtered.contains("TypeScript code"));
810 }
811
812 #[test]
813 fn test_filter_diff_special_characters_in_path() {
814 let diff = "diff --git a/path with spaces/file.rs b/path with spaces/file.rs\n+code\n";
815 let patterns = vec!["*.json".to_string()];
816 let filtered = filter_diff_by_globs(diff, &patterns);
817 assert!(filtered.contains("code"));
818 }
819
820 #[test]
821 fn test_compute_next_minor_tag_large_version() {
822 let result = compute_next_minor_tag(Some("99.999.0")).unwrap();
823 assert_eq!(result, "99.1000.0");
824 }
825
826 #[test]
827 fn test_compute_next_minor_tag_error_propagation() {
828 let result = compute_next_minor_tag(Some("not-semver"));
830 assert!(result.is_err());
831 }
832}