1use std::path::Path;
155use std::sync::LazyLock;
156
157use regex::Regex;
158
159#[derive(Debug, Default, Clone, PartialEq, Eq)]
168pub struct GitContextUpdate {
169 pub github_repo: Option<String>,
171 pub github_issue: Option<i32>,
173 pub github_pr: Option<i32>,
175 pub git_additions: Option<i32>,
177 pub git_deletions: Option<i32>,
179 pub worktree_path: Option<String>,
181 pub worktree_branch: Option<String>,
183 pub git_branch: Option<String>,
185}
186
187impl GitContextUpdate {
188 pub fn has_values(&self) -> bool {
190 self.github_repo.is_some()
191 || self.github_issue.is_some()
192 || self.github_pr.is_some()
193 || self.git_additions.is_some()
194 || self.git_deletions.is_some()
195 || self.worktree_path.is_some()
196 || self.worktree_branch.is_some()
197 || self.git_branch.is_some()
198 }
199
200 pub fn merge(&mut self, other: GitContextUpdate) {
202 if other.github_repo.is_some() {
203 self.github_repo = other.github_repo;
204 }
205 if other.github_issue.is_some() {
206 self.github_issue = other.github_issue;
207 }
208 if other.github_pr.is_some() {
209 self.github_pr = other.github_pr;
210 }
211 if other.git_additions.is_some() {
212 self.git_additions = other.git_additions;
213 }
214 if other.git_deletions.is_some() {
215 self.git_deletions = other.git_deletions;
216 }
217 if other.worktree_path.is_some() {
218 self.worktree_path = other.worktree_path;
219 }
220 if other.worktree_branch.is_some() {
221 self.worktree_branch = other.worktree_branch;
222 }
223 if other.git_branch.is_some() {
224 self.git_branch = other.git_branch;
225 }
226 }
227}
228
229#[expect(
234 clippy::expect_used,
235 reason = "static regex pattern verified at compile time"
236)]
237static GH_ISSUE_PR_RE: LazyLock<Regex> = LazyLock::new(|| {
238 Regex::new(
240 r"gh\s+(issue|pr)\s+(view|checks|diff|merge|close|edit|reopen|comment)\s+(\d+)(?:.*--repo\s+([^\s]+))?"
241 ).expect("invalid regex")
242});
243
244#[expect(
245 clippy::expect_used,
246 reason = "static regex pattern verified at compile time"
247)]
248static GITHUB_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
249 Regex::new(r"https://github\.com/([^/]+/[^/]+)/(issues|pull)/(\d+)").expect("invalid regex")
251});
252
253#[expect(
254 clippy::expect_used,
255 reason = "static regex pattern verified at compile time"
256)]
257static CLOSES_RE: LazyLock<Regex> = LazyLock::new(|| {
258 Regex::new(r"(?i)\b(closes?|fixes?|resolves?)\s*#(\d+)").expect("invalid regex")
260});
261
262#[expect(
263 clippy::expect_used,
264 reason = "static regex pattern verified at compile time"
265)]
266static GH_PR_CREATE_BODY_RE: LazyLock<Regex> = LazyLock::new(|| {
267 Regex::new(r#"gh\s+pr\s+create\s+.*--body\s+(?:"([^"]*)"|'([^']*)')"#).expect("invalid regex")
270});
271
272#[expect(
273 clippy::expect_used,
274 reason = "static regex pattern verified at compile time"
275)]
276static GIT_WORKTREE_ADD_RE: LazyLock<Regex> = LazyLock::new(|| {
277 Regex::new(r"git\s+worktree\s+add\s+(?:-f\s+)?([^\s]+)(?:.*-b\s+([^\s]+))?")
279 .expect("invalid regex")
280});
281
282#[expect(
283 clippy::expect_used,
284 reason = "static regex pattern verified at compile time"
285)]
286static GIT_C_PATH_RE: LazyLock<Regex> = LazyLock::new(|| {
287 Regex::new(r"git\s+-C\s+([^\s]+)").expect("invalid regex")
289});
290
291#[expect(
292 clippy::expect_used,
293 reason = "static regex pattern verified at compile time"
294)]
295static CD_PATH_RE: LazyLock<Regex> = LazyLock::new(|| {
296 Regex::new(r#"cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))"#).expect("invalid regex")
298});
299
300#[expect(
301 clippy::expect_used,
302 reason = "static regex pattern verified at compile time"
303)]
304static GH_CREATE_RE: LazyLock<Regex> = LazyLock::new(|| {
305 Regex::new(r"gh\s+(issue|pr)\s+create").expect("invalid regex")
307});
308
309#[expect(
310 clippy::expect_used,
311 reason = "static regex pattern verified at compile time"
312)]
313static GH_PR_VIEW_ADDITIONS_RE: LazyLock<Regex> = LazyLock::new(|| {
314 Regex::new(r"additions:\s+(\d+)").expect("invalid regex")
316});
317
318#[expect(
319 clippy::expect_used,
320 reason = "static regex pattern verified at compile time"
321)]
322static GH_PR_VIEW_DELETIONS_RE: LazyLock<Regex> = LazyLock::new(|| {
323 Regex::new(r"deletions:\s+(\d+)").expect("invalid regex")
325});
326
327#[expect(
328 clippy::expect_used,
329 reason = "static regex pattern verified at compile time"
330)]
331static GH_PR_VIEW_RE: LazyLock<Regex> = LazyLock::new(|| {
332 Regex::new(r"gh\s+pr\s+view\b").expect("invalid regex")
334});
335
336pub fn parse_gh_command(command: &str) -> GitContextUpdate {
343 let mut update = GitContextUpdate::default();
344
345 for part in split_chained_commands(command) {
347 if let Some(caps) = GH_ISSUE_PR_RE.captures(part) {
348 let cmd_type = caps.get(1).map(|m| m.as_str());
349 let number_str = caps.get(3).map(|m| m.as_str());
350 let repo = caps.get(4).map(|m| m.as_str().to_string());
351
352 if let Some(num) = number_str.and_then(|s| s.parse::<i32>().ok()) {
353 match cmd_type {
354 Some("issue") => update.github_issue = Some(num),
355 Some("pr") => update.github_pr = Some(num),
356 _ => {}
357 }
358 }
359
360 if let Some(r) = repo {
361 update.github_repo = Some(r);
362 }
363 }
364
365 if let Some(caps) = GH_PR_CREATE_BODY_RE.captures(part) {
367 let body = caps.get(1).or_else(|| caps.get(2)).map(|m| m.as_str());
368 if let Some(body_text) = body {
369 let issues = parse_closes_references(body_text);
370 if let Some(&first_issue) = issues.first() {
371 update.github_issue = Some(first_issue);
372 }
373 }
374 }
375 }
376
377 update
378}
379
380pub fn parse_github_urls(output: &str) -> GitContextUpdate {
386 let mut update = GitContextUpdate::default();
387
388 for caps in GITHUB_URL_RE.captures_iter(output) {
389 let repo = caps.get(1).map(|m| m.as_str().to_string());
390 let url_type = caps.get(2).map(|m| m.as_str());
391 let number_str = caps.get(3).map(|m| m.as_str());
392
393 if let Some(num) = number_str.and_then(|s| s.parse::<i32>().ok()) {
394 match url_type {
395 Some("issues") => update.github_issue = Some(num),
396 Some("pull") => update.github_pr = Some(num),
397 _ => {}
398 }
399 }
400
401 if let Some(r) = repo {
402 update.github_repo = Some(r);
403 }
404 }
405
406 update
407}
408
409pub fn parse_closes_references(body: &str) -> Vec<i32> {
413 CLOSES_RE
414 .captures_iter(body)
415 .filter_map(|caps| caps.get(2).and_then(|m| m.as_str().parse::<i32>().ok()))
416 .collect()
417}
418
419pub fn parse_worktree_add(command: &str, cwd: &Path) -> Option<(String, Option<String>)> {
426 for part in split_chained_commands(command) {
427 if let Some(caps) = GIT_WORKTREE_ADD_RE.captures(part) {
428 let path_str = caps.get(1)?.as_str();
429 let branch = caps.get(2).map(|m| m.as_str().to_string());
430
431 let resolved = resolve_path(path_str, cwd);
433 return Some((resolved, branch));
434 }
435 }
436 None
437}
438
439pub fn parse_git_c_path(command: &str, cwd: &Path) -> Option<String> {
443 for part in split_chained_commands(command) {
444 if let Some(caps) = GIT_C_PATH_RE.captures(part) {
445 let path_str = caps.get(1)?.as_str();
446 return Some(resolve_path(path_str, cwd));
447 }
448 }
449 None
450}
451
452pub fn parse_cd_path(command: &str, cwd: &Path) -> Option<String> {
459 for part in split_chained_commands(command) {
460 if let Some(caps) = CD_PATH_RE.captures(part) {
461 let path_str = caps
463 .get(1)
464 .or_else(|| caps.get(2))
465 .or_else(|| caps.get(3))?
466 .as_str();
467 return Some(resolve_path(path_str, cwd));
468 }
469 }
470 None
471}
472
473pub fn parse_gh_pr_view_stats(output: &str) -> GitContextUpdate {
481 let mut update = GitContextUpdate::default();
482
483 if let Some(caps) = GH_PR_VIEW_ADDITIONS_RE.captures(output)
484 && let Some(m) = caps.get(1)
485 && let Ok(additions) = m.as_str().parse::<i32>()
486 {
487 update.git_additions = Some(additions);
488 }
489
490 if let Some(caps) = GH_PR_VIEW_DELETIONS_RE.captures(output)
491 && let Some(m) = caps.get(1)
492 && let Ok(deletions) = m.as_str().parse::<i32>()
493 {
494 update.git_deletions = Some(deletions);
495 }
496
497 update
498}
499
500pub fn is_gh_pr_view_command(command: &str) -> bool {
504 for part in split_chained_commands(command) {
505 if GH_PR_VIEW_RE.is_match(part) {
506 return true;
507 }
508 }
509 false
510}
511
512fn should_parse_output_urls(command: &str) -> bool {
521 for part in split_chained_commands(command) {
522 if GH_CREATE_RE.is_match(part) {
523 return true;
524 }
525 }
526 false
527}
528
529pub fn extract_context(command: &str, output: Option<&str>, cwd: &Path) -> GitContextUpdate {
541 let mut update = GitContextUpdate::default();
542
543 update.merge(parse_gh_command(command));
545
546 if let Some(out) = output
550 && should_parse_output_urls(command)
551 {
552 update.merge(parse_github_urls(out));
553 }
554
555 if let Some(out) = output
557 && is_gh_pr_view_command(command)
558 {
559 update.merge(parse_gh_pr_view_stats(out));
560 }
561
562 if let Some((path, branch)) = parse_worktree_add(command, cwd) {
564 update.worktree_path = Some(path);
565 update.worktree_branch = branch;
566 }
567
568 if let Some(path) = parse_git_c_path(command, cwd) {
570 update.worktree_path = Some(path);
571 }
572
573 if let Some(path) = parse_cd_path(command, cwd) {
575 update.worktree_path = Some(path);
576 }
577
578 update
579}
580
581pub fn split_chained_commands(command: &str) -> Vec<&str> {
598 let mut parts = Vec::new();
601 let mut current = command;
602
603 while !current.is_empty() {
605 let delim_pos = [
607 current.find("&&").map(|p| (p, 2)),
608 current.find("||").map(|p| (p, 2)),
609 current.find(';').map(|p| (p, 1)),
610 ]
611 .into_iter()
612 .flatten()
613 .min_by_key(|(pos, _)| *pos);
614
615 if let Some((pos, len)) = delim_pos {
616 let part = current[..pos].trim();
617 if !part.is_empty() {
618 parts.push(part);
619 }
620 current = ¤t[pos + len..];
621 } else {
622 let part = current.trim();
623 if !part.is_empty() {
624 parts.push(part);
625 }
626 break;
627 }
628 }
629
630 parts
631}
632
633fn resolve_path(path: &str, cwd: &Path) -> String {
635 let path = if path.starts_with('~') {
636 if let Ok(home) = std::env::var("HOME") {
638 path.replacen('~', &home, 1)
639 } else {
640 path.to_string()
641 }
642 } else {
643 path.to_string()
644 };
645
646 let path = Path::new(&path);
647 if path.is_absolute() {
648 path.to_string_lossy().into_owned()
649 } else {
650 cwd.join(path).to_string_lossy().into_owned()
651 }
652}
653
654#[cfg(test)]
655#[expect(clippy::unwrap_used, reason = "test code uses unwrap for clarity")]
656mod tests {
657 use super::*;
658 use std::path::PathBuf;
659
660 #[test]
661 fn test_parse_gh_issue_view() {
662 let update = parse_gh_command("gh issue view 52");
663 assert_eq!(update.github_issue, Some(52));
664 assert_eq!(update.github_pr, None);
665 assert_eq!(update.github_repo, None);
666 }
667
668 #[test]
669 fn test_parse_gh_issue_with_repo() {
670 let update = parse_gh_command("gh issue view 52 --repo paradigmxyz/mi6");
671 assert_eq!(update.github_issue, Some(52));
672 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
673 }
674
675 #[test]
676 fn test_parse_gh_pr_checks() {
677 let update = parse_gh_command("gh pr checks 86");
678 assert_eq!(update.github_pr, Some(86));
679 assert_eq!(update.github_issue, None);
680 }
681
682 #[test]
683 fn test_parse_gh_pr_with_repo() {
684 let update = parse_gh_command("gh pr view 64 --repo paradigmxyz/mi6");
685 assert_eq!(update.github_pr, Some(64));
686 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
687 }
688
689 #[test]
690 fn test_parse_github_url_issue() {
691 let update = parse_github_urls("Created https://github.com/paradigmxyz/mi6/issues/236");
692 assert_eq!(update.github_issue, Some(236));
693 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
694 }
695
696 #[test]
697 fn test_parse_github_url_pr() {
698 let update = parse_github_urls("Created https://github.com/paradigmxyz/mi6/pull/237");
699 assert_eq!(update.github_pr, Some(237));
700 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
701 }
702
703 #[test]
704 fn test_parse_closes_references() {
705 let issues = parse_closes_references("This PR fixes #52 and closes #53");
706 assert_eq!(issues, vec![52, 53]);
707 }
708
709 #[test]
710 fn test_parse_closes_references_case_insensitive() {
711 let issues = parse_closes_references("FIXES #42\nResolves #43\ncloses #44");
712 assert_eq!(issues, vec![42, 43, 44]);
713 }
714
715 #[test]
716 fn test_parse_gh_pr_create_with_fixes() {
717 let update = parse_gh_command(r#"gh pr create --title "Fix bug" --body "Fixes #52""#);
718 assert_eq!(update.github_issue, Some(52));
719 }
720
721 #[test]
722 fn test_parse_worktree_add_simple() {
723 let cwd = PathBuf::from("/test/repos/mi6");
724 let result = parse_worktree_add("git worktree add ../mi6-issue-42", &cwd);
725 assert!(result.is_some());
726 let (path, branch) = result.unwrap();
727 assert_eq!(path, "/test/repos/mi6/../mi6-issue-42");
728 assert_eq!(branch, None);
729 }
730
731 #[test]
732 fn test_parse_worktree_add_with_branch() {
733 let cwd = PathBuf::from("/test/repos/mi6");
734 let result = parse_worktree_add(
735 "git worktree add ../mi6-fix -b fix/issue-42 origin/main",
736 &cwd,
737 );
738 assert!(result.is_some());
739 let (path, branch) = result.unwrap();
740 assert_eq!(path, "/test/repos/mi6/../mi6-fix");
741 assert_eq!(branch, Some("fix/issue-42".to_string()));
742 }
743
744 #[test]
745 fn test_parse_git_c_path() {
746 let cwd = PathBuf::from("/test/repos");
747 let cmd = "git -C /test/worktree status";
748 let result = parse_git_c_path(cmd, &cwd);
749 assert_eq!(result, Some("/test/worktree".to_string()));
750 }
751
752 #[test]
753 fn test_parse_cd_path_unquoted() {
754 let cwd = PathBuf::from("/test");
755 let cmd = "cd /test/path/to/dir";
756 let result = parse_cd_path(cmd, &cwd);
757 assert_eq!(result, Some("/test/path/to/dir".to_string()));
758 }
759
760 #[test]
761 fn test_parse_cd_path_quoted() {
762 let cwd = PathBuf::from("/test");
763 let cmd = r#"cd "/test/path with spaces/dir""#;
764 let result = parse_cd_path(cmd, &cwd);
765 assert_eq!(result, Some("/test/path with spaces/dir".to_string()));
766 }
767
768 #[test]
769 fn test_parse_cd_path_relative() {
770 let cwd = PathBuf::from("/test/repos");
771 let result = parse_cd_path("cd mi6-issue-42", &cwd);
772 assert_eq!(result, Some("/test/repos/mi6-issue-42".to_string()));
773 }
774
775 #[test]
776 fn test_chained_commands() {
777 let update =
778 parse_gh_command("cargo build && gh pr create --title 'Fix' --body 'Fixes #52'");
779 assert_eq!(update.github_issue, Some(52));
780 }
781
782 #[test]
783 fn test_chained_commands_semicolon() {
784 let update = parse_gh_command("echo hello; gh issue view 42");
785 assert_eq!(update.github_issue, Some(42));
786 }
787
788 #[test]
789 fn test_extract_context_combined() {
790 let cwd = PathBuf::from("/test/repos/mi6");
791 let update = extract_context(
792 "gh pr view 86 --repo paradigmxyz/mi6",
793 Some("Some output here"),
794 &cwd,
795 );
796 assert_eq!(update.github_pr, Some(86));
797 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
798 }
799
800 #[test]
801 fn test_extract_context_from_output() {
802 let cwd = PathBuf::from("/test/repos/mi6");
803 let update = extract_context(
804 "gh pr create --title 'Fix' --body 'Some fix'",
805 Some("Created https://github.com/paradigmxyz/mi6/pull/237"),
806 &cwd,
807 );
808 assert_eq!(update.github_pr, Some(237));
809 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
810 }
811
812 #[test]
813 fn test_session_context_update_has_values() {
814 let empty = GitContextUpdate::default();
815 assert!(!empty.has_values());
816
817 let with_issue = GitContextUpdate {
818 github_issue: Some(42),
819 ..Default::default()
820 };
821 assert!(with_issue.has_values());
822
823 let with_branch = GitContextUpdate {
825 git_branch: Some("feature/test".to_string()),
826 ..Default::default()
827 };
828 assert!(with_branch.has_values());
829 }
830
831 #[test]
832 fn test_session_context_update_merge() {
833 let mut base = GitContextUpdate {
834 github_repo: Some("foo/bar".to_string()),
835 github_issue: Some(1),
836 ..Default::default()
837 };
838
839 let other = GitContextUpdate {
840 github_issue: Some(2),
841 github_pr: Some(42),
842 ..Default::default()
843 };
844
845 base.merge(other);
846
847 assert_eq!(base.github_repo, Some("foo/bar".to_string())); assert_eq!(base.github_issue, Some(2)); assert_eq!(base.github_pr, Some(42)); }
851
852 #[test]
853 fn test_session_context_update_merge_git_branch() {
854 let mut base = GitContextUpdate {
855 github_pr: Some(42),
856 git_branch: Some("main".to_string()),
857 ..Default::default()
858 };
859
860 let other = GitContextUpdate {
861 git_branch: Some("feature/fix".to_string()),
862 ..Default::default()
863 };
864
865 base.merge(other);
866
867 assert_eq!(base.github_pr, Some(42)); assert_eq!(base.git_branch, Some("feature/fix".to_string())); }
870
871 #[test]
872 fn test_should_parse_output_urls() {
873 assert!(should_parse_output_urls("gh pr create --title 'Fix'"));
875 assert!(should_parse_output_urls("gh issue create --title 'Bug'"));
876 assert!(should_parse_output_urls(
877 "cargo build && gh pr create --title 'Fix'"
878 ));
879
880 assert!(!should_parse_output_urls("gh run view 12345"));
882 assert!(!should_parse_output_urls("gh pr view 42"));
883 assert!(!should_parse_output_urls("gh issue view 42"));
884 assert!(!should_parse_output_urls("cat README.md"));
885 assert!(!should_parse_output_urls(
886 "gh run download 12345 --name logs"
887 ));
888 }
889
890 #[test]
891 fn test_extract_context_ignores_urls_in_non_creation_output() {
892 let cwd = PathBuf::from("/test/repos/mi6");
896 let ci_log_output = r#"
897 # GitHub does not enforce `required: true` inputs itself. https://github.com/actions/runner/issues/1070
898 echo "toolchain=$toolchain" >> $GITHUB_OUTPUT
899 "#;
900
901 let update = extract_context("gh run view 12345 --log", Some(ci_log_output), &cwd);
902
903 assert_eq!(update.github_issue, None);
905 assert_eq!(update.github_repo, None);
906 assert_eq!(update.github_pr, None);
907 }
908
909 #[test]
910 fn test_extract_context_issue_create_output() {
911 let cwd = PathBuf::from("/test/repos/mi6");
912 let update = extract_context(
913 "gh issue create --title 'Bug report'",
914 Some("https://github.com/paradigmxyz/mi6/issues/42"),
915 &cwd,
916 );
917 assert_eq!(update.github_issue, Some(42));
918 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
919 }
920
921 #[test]
922 fn test_parse_gh_pr_view_stats() {
923 let output = r#"title: feat(theme): add pane backgrounds
924additions: 148
925deletions: 7
926"#;
927 let update = parse_gh_pr_view_stats(output);
928 assert_eq!(update.git_additions, Some(148));
929 assert_eq!(update.git_deletions, Some(7));
930 }
931
932 #[test]
933 fn test_parse_gh_pr_view_stats_with_spaces() {
934 let output = "additions: 50\ndeletions: 25";
935 let update = parse_gh_pr_view_stats(output);
936 assert_eq!(update.git_additions, Some(50));
937 assert_eq!(update.git_deletions, Some(25));
938 }
939
940 #[test]
941 fn test_is_gh_pr_view_command() {
942 assert!(is_gh_pr_view_command("gh pr view 42"));
943 assert!(is_gh_pr_view_command("gh pr view"));
944 assert!(is_gh_pr_view_command("gh pr view --json additions"));
945 assert!(!is_gh_pr_view_command("gh pr create"));
946 assert!(!is_gh_pr_view_command("gh issue view 42"));
947 assert!(!is_gh_pr_view_command("git status"));
948 }
949
950 #[test]
951 fn test_extract_context_gh_pr_view_stats() {
952 let cwd = PathBuf::from("/test/repos/mi6");
953 let output = "additions:\t148\ndeletions:\t7";
954 let update = extract_context("gh pr view 497", Some(output), &cwd);
955 assert_eq!(update.git_additions, Some(148));
956 assert_eq!(update.git_deletions, Some(7));
957 assert_eq!(update.github_pr, Some(497));
958 }
959}