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#[deprecated(since = "0.4.0", note = "Renamed to GitContextUpdate for clarity")]
231pub type SessionContextUpdate = GitContextUpdate;
232
233#[expect(
238 clippy::expect_used,
239 reason = "static regex pattern verified at compile time"
240)]
241static GH_ISSUE_PR_RE: LazyLock<Regex> = LazyLock::new(|| {
242 Regex::new(
244 r"gh\s+(issue|pr)\s+(view|checks|diff|merge|close|edit|reopen|comment)\s+(\d+)(?:.*--repo\s+([^\s]+))?"
245 ).expect("invalid regex")
246});
247
248#[expect(
249 clippy::expect_used,
250 reason = "static regex pattern verified at compile time"
251)]
252static GITHUB_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
253 Regex::new(r"https://github\.com/([^/]+/[^/]+)/(issues|pull)/(\d+)").expect("invalid regex")
255});
256
257#[expect(
258 clippy::expect_used,
259 reason = "static regex pattern verified at compile time"
260)]
261static CLOSES_RE: LazyLock<Regex> = LazyLock::new(|| {
262 Regex::new(r"(?i)\b(closes?|fixes?|resolves?)\s*#(\d+)").expect("invalid regex")
264});
265
266#[expect(
267 clippy::expect_used,
268 reason = "static regex pattern verified at compile time"
269)]
270static GH_PR_CREATE_BODY_RE: LazyLock<Regex> = LazyLock::new(|| {
271 Regex::new(r#"gh\s+pr\s+create\s+.*--body\s+(?:"([^"]*)"|'([^']*)')"#).expect("invalid regex")
274});
275
276#[expect(
277 clippy::expect_used,
278 reason = "static regex pattern verified at compile time"
279)]
280static GIT_WORKTREE_ADD_RE: LazyLock<Regex> = LazyLock::new(|| {
281 Regex::new(r"git\s+worktree\s+add\s+(?:-f\s+)?([^\s]+)(?:.*-b\s+([^\s]+))?")
283 .expect("invalid regex")
284});
285
286#[expect(
287 clippy::expect_used,
288 reason = "static regex pattern verified at compile time"
289)]
290static GIT_C_PATH_RE: LazyLock<Regex> = LazyLock::new(|| {
291 Regex::new(r"git\s+-C\s+([^\s]+)").expect("invalid regex")
293});
294
295#[expect(
296 clippy::expect_used,
297 reason = "static regex pattern verified at compile time"
298)]
299static CD_PATH_RE: LazyLock<Regex> = LazyLock::new(|| {
300 Regex::new(r#"cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))"#).expect("invalid regex")
302});
303
304#[expect(
305 clippy::expect_used,
306 reason = "static regex pattern verified at compile time"
307)]
308static GH_CREATE_RE: LazyLock<Regex> = LazyLock::new(|| {
309 Regex::new(r"gh\s+(issue|pr)\s+create").expect("invalid regex")
311});
312
313#[expect(
314 clippy::expect_used,
315 reason = "static regex pattern verified at compile time"
316)]
317static GH_PR_VIEW_ADDITIONS_RE: LazyLock<Regex> = LazyLock::new(|| {
318 Regex::new(r"additions:\s+(\d+)").expect("invalid regex")
320});
321
322#[expect(
323 clippy::expect_used,
324 reason = "static regex pattern verified at compile time"
325)]
326static GH_PR_VIEW_DELETIONS_RE: LazyLock<Regex> = LazyLock::new(|| {
327 Regex::new(r"deletions:\s+(\d+)").expect("invalid regex")
329});
330
331#[expect(
332 clippy::expect_used,
333 reason = "static regex pattern verified at compile time"
334)]
335static GH_PR_VIEW_RE: LazyLock<Regex> = LazyLock::new(|| {
336 Regex::new(r"gh\s+pr\s+view\b").expect("invalid regex")
338});
339
340pub fn parse_gh_command(command: &str) -> GitContextUpdate {
347 let mut update = GitContextUpdate::default();
348
349 for part in split_chained_commands(command) {
351 if let Some(caps) = GH_ISSUE_PR_RE.captures(part) {
352 let cmd_type = caps.get(1).map(|m| m.as_str());
353 let number_str = caps.get(3).map(|m| m.as_str());
354 let repo = caps.get(4).map(|m| m.as_str().to_string());
355
356 if let Some(num) = number_str.and_then(|s| s.parse::<i32>().ok()) {
357 match cmd_type {
358 Some("issue") => update.github_issue = Some(num),
359 Some("pr") => update.github_pr = Some(num),
360 _ => {}
361 }
362 }
363
364 if let Some(r) = repo {
365 update.github_repo = Some(r);
366 }
367 }
368
369 if let Some(caps) = GH_PR_CREATE_BODY_RE.captures(part) {
371 let body = caps.get(1).or_else(|| caps.get(2)).map(|m| m.as_str());
372 if let Some(body_text) = body {
373 let issues = parse_closes_references(body_text);
374 if let Some(&first_issue) = issues.first() {
375 update.github_issue = Some(first_issue);
376 }
377 }
378 }
379 }
380
381 update
382}
383
384pub fn parse_github_urls(output: &str) -> GitContextUpdate {
390 let mut update = GitContextUpdate::default();
391
392 for caps in GITHUB_URL_RE.captures_iter(output) {
393 let repo = caps.get(1).map(|m| m.as_str().to_string());
394 let url_type = caps.get(2).map(|m| m.as_str());
395 let number_str = caps.get(3).map(|m| m.as_str());
396
397 if let Some(num) = number_str.and_then(|s| s.parse::<i32>().ok()) {
398 match url_type {
399 Some("issues") => update.github_issue = Some(num),
400 Some("pull") => update.github_pr = Some(num),
401 _ => {}
402 }
403 }
404
405 if let Some(r) = repo {
406 update.github_repo = Some(r);
407 }
408 }
409
410 update
411}
412
413pub fn parse_closes_references(body: &str) -> Vec<i32> {
417 CLOSES_RE
418 .captures_iter(body)
419 .filter_map(|caps| caps.get(2).and_then(|m| m.as_str().parse::<i32>().ok()))
420 .collect()
421}
422
423pub fn parse_worktree_add(command: &str, cwd: &Path) -> Option<(String, Option<String>)> {
430 for part in split_chained_commands(command) {
431 if let Some(caps) = GIT_WORKTREE_ADD_RE.captures(part) {
432 let path_str = caps.get(1)?.as_str();
433 let branch = caps.get(2).map(|m| m.as_str().to_string());
434
435 let resolved = resolve_path(path_str, cwd);
437 return Some((resolved, branch));
438 }
439 }
440 None
441}
442
443pub fn parse_git_c_path(command: &str, cwd: &Path) -> Option<String> {
447 for part in split_chained_commands(command) {
448 if let Some(caps) = GIT_C_PATH_RE.captures(part) {
449 let path_str = caps.get(1)?.as_str();
450 return Some(resolve_path(path_str, cwd));
451 }
452 }
453 None
454}
455
456pub fn parse_cd_path(command: &str, cwd: &Path) -> Option<String> {
463 for part in split_chained_commands(command) {
464 if let Some(caps) = CD_PATH_RE.captures(part) {
465 let path_str = caps
467 .get(1)
468 .or_else(|| caps.get(2))
469 .or_else(|| caps.get(3))?
470 .as_str();
471 return Some(resolve_path(path_str, cwd));
472 }
473 }
474 None
475}
476
477pub fn parse_gh_pr_view_stats(output: &str) -> GitContextUpdate {
485 let mut update = GitContextUpdate::default();
486
487 if let Some(caps) = GH_PR_VIEW_ADDITIONS_RE.captures(output)
488 && let Some(m) = caps.get(1)
489 && let Ok(additions) = m.as_str().parse::<i32>()
490 {
491 update.git_additions = Some(additions);
492 }
493
494 if let Some(caps) = GH_PR_VIEW_DELETIONS_RE.captures(output)
495 && let Some(m) = caps.get(1)
496 && let Ok(deletions) = m.as_str().parse::<i32>()
497 {
498 update.git_deletions = Some(deletions);
499 }
500
501 update
502}
503
504pub fn is_gh_pr_view_command(command: &str) -> bool {
508 for part in split_chained_commands(command) {
509 if GH_PR_VIEW_RE.is_match(part) {
510 return true;
511 }
512 }
513 false
514}
515
516fn should_parse_output_urls(command: &str) -> bool {
525 for part in split_chained_commands(command) {
526 if GH_CREATE_RE.is_match(part) {
527 return true;
528 }
529 }
530 false
531}
532
533pub fn extract_context(command: &str, output: Option<&str>, cwd: &Path) -> GitContextUpdate {
545 let mut update = GitContextUpdate::default();
546
547 update.merge(parse_gh_command(command));
549
550 if let Some(out) = output
554 && should_parse_output_urls(command)
555 {
556 update.merge(parse_github_urls(out));
557 }
558
559 if let Some(out) = output
561 && is_gh_pr_view_command(command)
562 {
563 update.merge(parse_gh_pr_view_stats(out));
564 }
565
566 if let Some((path, branch)) = parse_worktree_add(command, cwd) {
568 update.worktree_path = Some(path);
569 update.worktree_branch = branch;
570 }
571
572 if let Some(path) = parse_git_c_path(command, cwd) {
574 update.worktree_path = Some(path);
575 }
576
577 if let Some(path) = parse_cd_path(command, cwd) {
579 update.worktree_path = Some(path);
580 }
581
582 update
583}
584
585pub fn split_chained_commands(command: &str) -> Vec<&str> {
602 let mut parts = Vec::new();
605 let mut current = command;
606
607 while !current.is_empty() {
609 let delim_pos = [
611 current.find("&&").map(|p| (p, 2)),
612 current.find("||").map(|p| (p, 2)),
613 current.find(';').map(|p| (p, 1)),
614 ]
615 .into_iter()
616 .flatten()
617 .min_by_key(|(pos, _)| *pos);
618
619 if let Some((pos, len)) = delim_pos {
620 let part = current[..pos].trim();
621 if !part.is_empty() {
622 parts.push(part);
623 }
624 current = ¤t[pos + len..];
625 } else {
626 let part = current.trim();
627 if !part.is_empty() {
628 parts.push(part);
629 }
630 break;
631 }
632 }
633
634 parts
635}
636
637fn resolve_path(path: &str, cwd: &Path) -> String {
639 let path = if path.starts_with('~') {
640 if let Ok(home) = std::env::var("HOME") {
642 path.replacen('~', &home, 1)
643 } else {
644 path.to_string()
645 }
646 } else {
647 path.to_string()
648 };
649
650 let path = Path::new(&path);
651 if path.is_absolute() {
652 path.to_string_lossy().into_owned()
653 } else {
654 cwd.join(path).to_string_lossy().into_owned()
655 }
656}
657
658#[cfg(test)]
659#[expect(clippy::unwrap_used, reason = "test code uses unwrap for clarity")]
660mod tests {
661 use super::*;
662 use std::path::PathBuf;
663
664 #[test]
665 fn test_parse_gh_issue_view() {
666 let update = parse_gh_command("gh issue view 52");
667 assert_eq!(update.github_issue, Some(52));
668 assert_eq!(update.github_pr, None);
669 assert_eq!(update.github_repo, None);
670 }
671
672 #[test]
673 fn test_parse_gh_issue_with_repo() {
674 let update = parse_gh_command("gh issue view 52 --repo paradigmxyz/mi6");
675 assert_eq!(update.github_issue, Some(52));
676 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
677 }
678
679 #[test]
680 fn test_parse_gh_pr_checks() {
681 let update = parse_gh_command("gh pr checks 86");
682 assert_eq!(update.github_pr, Some(86));
683 assert_eq!(update.github_issue, None);
684 }
685
686 #[test]
687 fn test_parse_gh_pr_with_repo() {
688 let update = parse_gh_command("gh pr view 64 --repo paradigmxyz/mi6");
689 assert_eq!(update.github_pr, Some(64));
690 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
691 }
692
693 #[test]
694 fn test_parse_github_url_issue() {
695 let update = parse_github_urls("Created https://github.com/paradigmxyz/mi6/issues/236");
696 assert_eq!(update.github_issue, Some(236));
697 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
698 }
699
700 #[test]
701 fn test_parse_github_url_pr() {
702 let update = parse_github_urls("Created https://github.com/paradigmxyz/mi6/pull/237");
703 assert_eq!(update.github_pr, Some(237));
704 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
705 }
706
707 #[test]
708 fn test_parse_closes_references() {
709 let issues = parse_closes_references("This PR fixes #52 and closes #53");
710 assert_eq!(issues, vec![52, 53]);
711 }
712
713 #[test]
714 fn test_parse_closes_references_case_insensitive() {
715 let issues = parse_closes_references("FIXES #42\nResolves #43\ncloses #44");
716 assert_eq!(issues, vec![42, 43, 44]);
717 }
718
719 #[test]
720 fn test_parse_gh_pr_create_with_fixes() {
721 let update = parse_gh_command(r#"gh pr create --title "Fix bug" --body "Fixes #52""#);
722 assert_eq!(update.github_issue, Some(52));
723 }
724
725 #[test]
726 fn test_parse_worktree_add_simple() {
727 let cwd = PathBuf::from("/test/repos/mi6");
728 let result = parse_worktree_add("git worktree add ../mi6-issue-42", &cwd);
729 assert!(result.is_some());
730 let (path, branch) = result.unwrap();
731 assert_eq!(path, "/test/repos/mi6/../mi6-issue-42");
732 assert_eq!(branch, None);
733 }
734
735 #[test]
736 fn test_parse_worktree_add_with_branch() {
737 let cwd = PathBuf::from("/test/repos/mi6");
738 let result = parse_worktree_add(
739 "git worktree add ../mi6-fix -b fix/issue-42 origin/main",
740 &cwd,
741 );
742 assert!(result.is_some());
743 let (path, branch) = result.unwrap();
744 assert_eq!(path, "/test/repos/mi6/../mi6-fix");
745 assert_eq!(branch, Some("fix/issue-42".to_string()));
746 }
747
748 #[test]
749 fn test_parse_git_c_path() {
750 let cwd = PathBuf::from("/test/repos");
751 let cmd = "git -C /test/worktree status";
752 let result = parse_git_c_path(cmd, &cwd);
753 assert_eq!(result, Some("/test/worktree".to_string()));
754 }
755
756 #[test]
757 fn test_parse_cd_path_unquoted() {
758 let cwd = PathBuf::from("/test");
759 let cmd = "cd /test/path/to/dir";
760 let result = parse_cd_path(cmd, &cwd);
761 assert_eq!(result, Some("/test/path/to/dir".to_string()));
762 }
763
764 #[test]
765 fn test_parse_cd_path_quoted() {
766 let cwd = PathBuf::from("/test");
767 let cmd = r#"cd "/test/path with spaces/dir""#;
768 let result = parse_cd_path(cmd, &cwd);
769 assert_eq!(result, Some("/test/path with spaces/dir".to_string()));
770 }
771
772 #[test]
773 fn test_parse_cd_path_relative() {
774 let cwd = PathBuf::from("/test/repos");
775 let result = parse_cd_path("cd mi6-issue-42", &cwd);
776 assert_eq!(result, Some("/test/repos/mi6-issue-42".to_string()));
777 }
778
779 #[test]
780 fn test_chained_commands() {
781 let update =
782 parse_gh_command("cargo build && gh pr create --title 'Fix' --body 'Fixes #52'");
783 assert_eq!(update.github_issue, Some(52));
784 }
785
786 #[test]
787 fn test_chained_commands_semicolon() {
788 let update = parse_gh_command("echo hello; gh issue view 42");
789 assert_eq!(update.github_issue, Some(42));
790 }
791
792 #[test]
793 fn test_extract_context_combined() {
794 let cwd = PathBuf::from("/test/repos/mi6");
795 let update = extract_context(
796 "gh pr view 86 --repo paradigmxyz/mi6",
797 Some("Some output here"),
798 &cwd,
799 );
800 assert_eq!(update.github_pr, Some(86));
801 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
802 }
803
804 #[test]
805 fn test_extract_context_from_output() {
806 let cwd = PathBuf::from("/test/repos/mi6");
807 let update = extract_context(
808 "gh pr create --title 'Fix' --body 'Some fix'",
809 Some("Created https://github.com/paradigmxyz/mi6/pull/237"),
810 &cwd,
811 );
812 assert_eq!(update.github_pr, Some(237));
813 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
814 }
815
816 #[test]
817 fn test_session_context_update_has_values() {
818 let empty = GitContextUpdate::default();
819 assert!(!empty.has_values());
820
821 let with_issue = GitContextUpdate {
822 github_issue: Some(42),
823 ..Default::default()
824 };
825 assert!(with_issue.has_values());
826
827 let with_branch = GitContextUpdate {
829 git_branch: Some("feature/test".to_string()),
830 ..Default::default()
831 };
832 assert!(with_branch.has_values());
833 }
834
835 #[test]
836 fn test_session_context_update_merge() {
837 let mut base = GitContextUpdate {
838 github_repo: Some("foo/bar".to_string()),
839 github_issue: Some(1),
840 ..Default::default()
841 };
842
843 let other = GitContextUpdate {
844 github_issue: Some(2),
845 github_pr: Some(42),
846 ..Default::default()
847 };
848
849 base.merge(other);
850
851 assert_eq!(base.github_repo, Some("foo/bar".to_string())); assert_eq!(base.github_issue, Some(2)); assert_eq!(base.github_pr, Some(42)); }
855
856 #[test]
857 fn test_session_context_update_merge_git_branch() {
858 let mut base = GitContextUpdate {
859 github_pr: Some(42),
860 git_branch: Some("main".to_string()),
861 ..Default::default()
862 };
863
864 let other = GitContextUpdate {
865 git_branch: Some("feature/fix".to_string()),
866 ..Default::default()
867 };
868
869 base.merge(other);
870
871 assert_eq!(base.github_pr, Some(42)); assert_eq!(base.git_branch, Some("feature/fix".to_string())); }
874
875 #[test]
876 fn test_should_parse_output_urls() {
877 assert!(should_parse_output_urls("gh pr create --title 'Fix'"));
879 assert!(should_parse_output_urls("gh issue create --title 'Bug'"));
880 assert!(should_parse_output_urls(
881 "cargo build && gh pr create --title 'Fix'"
882 ));
883
884 assert!(!should_parse_output_urls("gh run view 12345"));
886 assert!(!should_parse_output_urls("gh pr view 42"));
887 assert!(!should_parse_output_urls("gh issue view 42"));
888 assert!(!should_parse_output_urls("cat README.md"));
889 assert!(!should_parse_output_urls(
890 "gh run download 12345 --name logs"
891 ));
892 }
893
894 #[test]
895 fn test_extract_context_ignores_urls_in_non_creation_output() {
896 let cwd = PathBuf::from("/test/repos/mi6");
900 let ci_log_output = r#"
901 # GitHub does not enforce `required: true` inputs itself. https://github.com/actions/runner/issues/1070
902 echo "toolchain=$toolchain" >> $GITHUB_OUTPUT
903 "#;
904
905 let update = extract_context("gh run view 12345 --log", Some(ci_log_output), &cwd);
906
907 assert_eq!(update.github_issue, None);
909 assert_eq!(update.github_repo, None);
910 assert_eq!(update.github_pr, None);
911 }
912
913 #[test]
914 fn test_extract_context_issue_create_output() {
915 let cwd = PathBuf::from("/test/repos/mi6");
916 let update = extract_context(
917 "gh issue create --title 'Bug report'",
918 Some("https://github.com/paradigmxyz/mi6/issues/42"),
919 &cwd,
920 );
921 assert_eq!(update.github_issue, Some(42));
922 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
923 }
924
925 #[test]
926 fn test_parse_gh_pr_view_stats() {
927 let output = r#"title: feat(theme): add pane backgrounds
928additions: 148
929deletions: 7
930"#;
931 let update = parse_gh_pr_view_stats(output);
932 assert_eq!(update.git_additions, Some(148));
933 assert_eq!(update.git_deletions, Some(7));
934 }
935
936 #[test]
937 fn test_parse_gh_pr_view_stats_with_spaces() {
938 let output = "additions: 50\ndeletions: 25";
939 let update = parse_gh_pr_view_stats(output);
940 assert_eq!(update.git_additions, Some(50));
941 assert_eq!(update.git_deletions, Some(25));
942 }
943
944 #[test]
945 fn test_is_gh_pr_view_command() {
946 assert!(is_gh_pr_view_command("gh pr view 42"));
947 assert!(is_gh_pr_view_command("gh pr view"));
948 assert!(is_gh_pr_view_command("gh pr view --json additions"));
949 assert!(!is_gh_pr_view_command("gh pr create"));
950 assert!(!is_gh_pr_view_command("gh issue view 42"));
951 assert!(!is_gh_pr_view_command("git status"));
952 }
953
954 #[test]
955 fn test_extract_context_gh_pr_view_stats() {
956 let cwd = PathBuf::from("/test/repos/mi6");
957 let output = "additions:\t148\ndeletions:\t7";
958 let update = extract_context("gh pr view 497", Some(output), &cwd);
959 assert_eq!(update.git_additions, Some(148));
960 assert_eq!(update.git_deletions, Some(7));
961 assert_eq!(update.github_pr, Some(497));
962 }
963}