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
563 .unwrap_err()
564 .to_string()
565 .contains("not valid semantic"));
566 }
567
568 #[test]
569 fn test_parse_semver_tag_invalid_format_four_parts() {
570 let result = parse_semver_tag("1.2.3.4");
571 assert!(result.is_err());
572 }
573
574 #[test]
575 fn test_parse_semver_tag_non_numeric_major() {
576 let result = parse_semver_tag("a.2.3");
577 assert!(result.is_err());
578 }
579
580 #[test]
581 fn test_parse_semver_tag_non_numeric_minor() {
582 let result = parse_semver_tag("1.b.3");
583 assert!(result.is_err());
584 }
585
586 #[test]
587 fn test_parse_semver_tag_non_numeric_patch() {
588 let result = parse_semver_tag("1.2.c");
589 assert!(result.is_err());
590 }
591
592 #[test]
593 fn test_parse_semver_tag_with_v_prefix() {
594 let result = parse_semver_tag("v1.2.3");
596 assert!(result.is_err());
597 }
598
599 #[test]
600 fn test_parse_semver_tag_with_whitespace() {
601 let (major, minor, patch) = parse_semver_tag(" 1.2.3 ").unwrap();
603 assert_eq!((major, minor, patch), (1, 2, 3));
604 }
605
606 #[test]
607 fn test_script_command_unix_path() {
608 let path = std::path::Path::new("/tmp/script.sh");
609 let result = script_command(path);
610 assert_eq!(result, "/tmp/script.sh");
611 }
612
613 #[test]
614 fn test_script_command_windows_path() {
615 let path = std::path::Path::new("C:\\Users\\test\\script.sh");
616 let result = script_command(path);
617 assert!(result.contains('/'));
619 assert!(!result.contains('\\'));
620 }
621
622 #[test]
623 fn test_configure_stdio_suppress() {
624 let mut cmd = Command::new("echo");
625 configure_stdio(&mut cmd, true);
626 }
628
629 #[test]
630 fn test_configure_stdio_no_suppress() {
631 let mut cmd = Command::new("echo");
632 configure_stdio(&mut cmd, false);
633 }
635
636 #[test]
637 fn test_filter_diff_all_invalid_patterns() {
638 let diff = "diff --git a/test.rs b/test.rs\n+code\n";
639 let patterns = vec!["[invalid".to_string(), "[also[bad".to_string()];
641 let filtered = filter_diff_by_globs(diff, &patterns);
642 assert_eq!(filtered, diff);
643 }
644
645 #[test]
646 fn test_filter_diff_empty_diff() {
647 let patterns = vec!["*.json".to_string()];
648 let filtered = filter_diff_by_globs("", &patterns);
649 assert_eq!(filtered, "");
650 }
651
652 #[test]
653 fn test_filter_diff_no_diff_header() {
654 let content = "just some random text\nwithout diff headers";
656 let patterns = vec!["*.json".to_string()];
657 let filtered = filter_diff_by_globs(content, &patterns);
658 assert!(filtered.contains("random text"));
660 }
661
662 #[test]
663 fn test_filter_diff_malformed_header() {
664 let diff = "diff --git \n+something\n";
666 let patterns = vec!["*.json".to_string()];
667 let filtered = filter_diff_by_globs(diff, &patterns);
668 assert!(filtered.contains("something"));
670 }
671
672 #[test]
673 fn test_compute_next_minor_tag_increment() {
674 let result = compute_next_minor_tag(Some("2.5.9")).unwrap();
675 assert_eq!(result, "2.6.0");
676 }
677
678 #[test]
679 fn test_compute_next_minor_tag_none() {
680 let result = compute_next_minor_tag(None).unwrap();
681 assert_eq!(result, "0.1.0");
682 }
683
684 #[test]
685 fn test_make_executable_windows() {
686 let temp_dir = std::env::temp_dir();
688 let test_file = temp_dir.join("test_make_executable.sh");
689 std::fs::write(&test_file, "#!/bin/sh\necho test").unwrap();
690 let result = make_executable(&test_file);
691 assert!(result.is_ok());
692 let _ = std::fs::remove_file(&test_file);
693 }
694
695 #[test]
696 fn test_write_sequence_editor_script() {
697 let temp_dir = std::env::temp_dir();
698 let script_path = temp_dir.join("test_seq_editor.sh");
699 let result = write_sequence_editor_script(&script_path);
700 assert!(result.is_ok());
701 let content = std::fs::read_to_string(&script_path).unwrap();
702 assert!(content.contains("#!/bin/sh"));
703 assert!(content.contains("reword"));
704 let _ = std::fs::remove_file(&script_path);
705 }
706
707 #[test]
708 fn test_write_message_editor_script() {
709 let temp_dir = std::env::temp_dir();
710 let script_path = temp_dir.join("test_msg_editor.sh");
711 let result = write_message_editor_script(&script_path);
712 assert!(result.is_ok());
713 let content = std::fs::read_to_string(&script_path).unwrap();
714 assert!(content.contains("#!/bin/sh"));
715 assert!(content.contains("CGEN_NEW_MESSAGE"));
716 let _ = std::fs::remove_file(&script_path);
717 }
718
719 #[test]
720 fn test_filter_diff_single_file_excluded() {
721 let diff = "diff --git a/config.json b/config.json\n+{}\n";
722 let patterns = vec!["*.json".to_string()];
723 let filtered = filter_diff_by_globs(diff, &patterns);
724 assert!(filtered.is_empty() || !filtered.contains("config.json"));
725 }
726
727 #[test]
728 fn test_filter_diff_preserves_context_lines() {
729 let diff = r#"diff --git a/main.rs b/main.rs
730--- a/main.rs
731+++ b/main.rs
732@@ -1,5 +1,6 @@
733 fn main() {
734+ println!("new");
735 old_code();
736 }
737"#;
738 let patterns = vec!["*.json".to_string()]; let filtered = filter_diff_by_globs(diff, &patterns);
740 assert!(filtered.contains("fn main()"));
741 assert!(filtered.contains("println!"));
742 assert!(filtered.contains("old_code"));
743 }
744
745 #[test]
746 fn test_filter_diff_consecutive_files() {
747 let diff = r#"diff --git a/a.json b/a.json
748+first
749diff --git a/b.json b/b.json
750+second
751diff --git a/c.rs b/c.rs
752+third
753"#;
754 let patterns = vec!["*.json".to_string()];
755 let filtered = filter_diff_by_globs(diff, &patterns);
756 assert!(!filtered.contains("first"));
757 assert!(!filtered.contains("second"));
758 assert!(filtered.contains("third"));
759 }
760
761 #[test]
762 fn test_parse_semver_tag_empty() {
763 let result = parse_semver_tag("");
764 assert!(result.is_err());
765 }
766
767 #[test]
768 fn test_parse_semver_tag_single_number() {
769 let result = parse_semver_tag("1");
770 assert!(result.is_err());
771 }
772
773 #[test]
774 fn test_script_command_empty_path() {
775 let path = std::path::Path::new("");
776 let result = script_command(path);
777 assert_eq!(result, "");
778 }
779
780 #[test]
781 fn test_script_command_relative_path() {
782 let path = std::path::Path::new("scripts/test.sh");
783 let result = script_command(path);
784 assert_eq!(result, "scripts/test.sh");
785 }
786
787 #[test]
788 fn test_filter_diff_mixed_content_types() {
789 let diff = r#"diff --git a/readme.md b/readme.md
790--- a/readme.md
791+++ b/readme.md
792@@ -1 +1,2 @@
793+Documentation
794diff --git a/package-lock.json b/package-lock.json
795--- a/package-lock.json
796+++ b/package-lock.json
797@@ -1 +1,2 @@
798+{"deps": true}
799diff --git a/src/app.ts b/src/app.ts
800--- a/src/app.ts
801+++ b/src/app.ts
802@@ -1 +1,2 @@
803+// TypeScript code
804"#;
805 let patterns = vec!["*.json".to_string()];
806 let filtered = filter_diff_by_globs(diff, &patterns);
807
808 assert!(filtered.contains("readme.md"));
809 assert!(filtered.contains("Documentation"));
810 assert!(!filtered.contains("package-lock.json"));
811 assert!(filtered.contains("src/app.ts"));
812 assert!(filtered.contains("TypeScript code"));
813 }
814
815 #[test]
816 fn test_filter_diff_special_characters_in_path() {
817 let diff = "diff --git a/path with spaces/file.rs b/path with spaces/file.rs\n+code\n";
818 let patterns = vec!["*.json".to_string()];
819 let filtered = filter_diff_by_globs(diff, &patterns);
820 assert!(filtered.contains("code"));
821 }
822
823 #[test]
824 fn test_compute_next_minor_tag_large_version() {
825 let result = compute_next_minor_tag(Some("99.999.0")).unwrap();
826 assert_eq!(result, "99.1000.0");
827 }
828
829 #[test]
830 fn test_compute_next_minor_tag_error_propagation() {
831 let result = compute_next_minor_tag(Some("not-semver"));
833 assert!(result.is_err());
834 }
835}