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 worktree_path: Option<String>,
177 pub worktree_branch: Option<String>,
179 pub git_branch: Option<String>,
181}
182
183impl GitContextUpdate {
184 pub fn has_values(&self) -> bool {
186 self.github_repo.is_some()
187 || self.github_issue.is_some()
188 || self.github_pr.is_some()
189 || self.worktree_path.is_some()
190 || self.worktree_branch.is_some()
191 || self.git_branch.is_some()
192 }
193
194 pub fn merge(&mut self, other: GitContextUpdate) {
196 if other.github_repo.is_some() {
197 self.github_repo = other.github_repo;
198 }
199 if other.github_issue.is_some() {
200 self.github_issue = other.github_issue;
201 }
202 if other.github_pr.is_some() {
203 self.github_pr = other.github_pr;
204 }
205 if other.worktree_path.is_some() {
206 self.worktree_path = other.worktree_path;
207 }
208 if other.worktree_branch.is_some() {
209 self.worktree_branch = other.worktree_branch;
210 }
211 if other.git_branch.is_some() {
212 self.git_branch = other.git_branch;
213 }
214 }
215}
216
217#[deprecated(since = "0.4.0", note = "Renamed to GitContextUpdate for clarity")]
219pub type SessionContextUpdate = GitContextUpdate;
220
221#[expect(
226 clippy::expect_used,
227 reason = "static regex pattern verified at compile time"
228)]
229static GH_ISSUE_PR_RE: LazyLock<Regex> = LazyLock::new(|| {
230 Regex::new(
232 r"gh\s+(issue|pr)\s+(view|checks|diff|merge|close|edit|reopen|comment)\s+(\d+)(?:.*--repo\s+([^\s]+))?"
233 ).expect("invalid regex")
234});
235
236#[expect(
237 clippy::expect_used,
238 reason = "static regex pattern verified at compile time"
239)]
240static GITHUB_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
241 Regex::new(r"https://github\.com/([^/]+/[^/]+)/(issues|pull)/(\d+)").expect("invalid regex")
243});
244
245#[expect(
246 clippy::expect_used,
247 reason = "static regex pattern verified at compile time"
248)]
249static CLOSES_RE: LazyLock<Regex> = LazyLock::new(|| {
250 Regex::new(r"(?i)\b(closes?|fixes?|resolves?)\s*#(\d+)").expect("invalid regex")
252});
253
254#[expect(
255 clippy::expect_used,
256 reason = "static regex pattern verified at compile time"
257)]
258static GH_PR_CREATE_BODY_RE: LazyLock<Regex> = LazyLock::new(|| {
259 Regex::new(r#"gh\s+pr\s+create\s+.*--body\s+(?:"([^"]*)"|'([^']*)')"#).expect("invalid regex")
262});
263
264#[expect(
265 clippy::expect_used,
266 reason = "static regex pattern verified at compile time"
267)]
268static GIT_WORKTREE_ADD_RE: LazyLock<Regex> = LazyLock::new(|| {
269 Regex::new(r"git\s+worktree\s+add\s+(?:-f\s+)?([^\s]+)(?:.*-b\s+([^\s]+))?")
271 .expect("invalid regex")
272});
273
274#[expect(
275 clippy::expect_used,
276 reason = "static regex pattern verified at compile time"
277)]
278static GIT_C_PATH_RE: LazyLock<Regex> = LazyLock::new(|| {
279 Regex::new(r"git\s+-C\s+([^\s]+)").expect("invalid regex")
281});
282
283#[expect(
284 clippy::expect_used,
285 reason = "static regex pattern verified at compile time"
286)]
287static CD_PATH_RE: LazyLock<Regex> = LazyLock::new(|| {
288 Regex::new(r#"cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))"#).expect("invalid regex")
290});
291
292#[expect(
293 clippy::expect_used,
294 reason = "static regex pattern verified at compile time"
295)]
296static GH_CREATE_RE: LazyLock<Regex> = LazyLock::new(|| {
297 Regex::new(r"gh\s+(issue|pr)\s+create").expect("invalid regex")
299});
300
301pub fn parse_gh_command(command: &str) -> GitContextUpdate {
308 let mut update = GitContextUpdate::default();
309
310 for part in split_chained_commands(command) {
312 if let Some(caps) = GH_ISSUE_PR_RE.captures(part) {
313 let cmd_type = caps.get(1).map(|m| m.as_str());
314 let number_str = caps.get(3).map(|m| m.as_str());
315 let repo = caps.get(4).map(|m| m.as_str().to_string());
316
317 if let Some(num) = number_str.and_then(|s| s.parse::<i32>().ok()) {
318 match cmd_type {
319 Some("issue") => update.github_issue = Some(num),
320 Some("pr") => update.github_pr = Some(num),
321 _ => {}
322 }
323 }
324
325 if let Some(r) = repo {
326 update.github_repo = Some(r);
327 }
328 }
329
330 if let Some(caps) = GH_PR_CREATE_BODY_RE.captures(part) {
332 let body = caps.get(1).or_else(|| caps.get(2)).map(|m| m.as_str());
333 if let Some(body_text) = body {
334 let issues = parse_closes_references(body_text);
335 if let Some(&first_issue) = issues.first() {
336 update.github_issue = Some(first_issue);
337 }
338 }
339 }
340 }
341
342 update
343}
344
345pub fn parse_github_urls(output: &str) -> GitContextUpdate {
351 let mut update = GitContextUpdate::default();
352
353 for caps in GITHUB_URL_RE.captures_iter(output) {
354 let repo = caps.get(1).map(|m| m.as_str().to_string());
355 let url_type = caps.get(2).map(|m| m.as_str());
356 let number_str = caps.get(3).map(|m| m.as_str());
357
358 if let Some(num) = number_str.and_then(|s| s.parse::<i32>().ok()) {
359 match url_type {
360 Some("issues") => update.github_issue = Some(num),
361 Some("pull") => update.github_pr = Some(num),
362 _ => {}
363 }
364 }
365
366 if let Some(r) = repo {
367 update.github_repo = Some(r);
368 }
369 }
370
371 update
372}
373
374pub fn parse_closes_references(body: &str) -> Vec<i32> {
378 CLOSES_RE
379 .captures_iter(body)
380 .filter_map(|caps| caps.get(2).and_then(|m| m.as_str().parse::<i32>().ok()))
381 .collect()
382}
383
384pub fn parse_worktree_add(command: &str, cwd: &Path) -> Option<(String, Option<String>)> {
391 for part in split_chained_commands(command) {
392 if let Some(caps) = GIT_WORKTREE_ADD_RE.captures(part) {
393 let path_str = caps.get(1)?.as_str();
394 let branch = caps.get(2).map(|m| m.as_str().to_string());
395
396 let resolved = resolve_path(path_str, cwd);
398 return Some((resolved, branch));
399 }
400 }
401 None
402}
403
404pub fn parse_git_c_path(command: &str, cwd: &Path) -> Option<String> {
408 for part in split_chained_commands(command) {
409 if let Some(caps) = GIT_C_PATH_RE.captures(part) {
410 let path_str = caps.get(1)?.as_str();
411 return Some(resolve_path(path_str, cwd));
412 }
413 }
414 None
415}
416
417pub fn parse_cd_path(command: &str, cwd: &Path) -> Option<String> {
424 for part in split_chained_commands(command) {
425 if let Some(caps) = CD_PATH_RE.captures(part) {
426 let path_str = caps
428 .get(1)
429 .or_else(|| caps.get(2))
430 .or_else(|| caps.get(3))?
431 .as_str();
432 return Some(resolve_path(path_str, cwd));
433 }
434 }
435 None
436}
437
438fn should_parse_output_urls(command: &str) -> bool {
447 for part in split_chained_commands(command) {
448 if GH_CREATE_RE.is_match(part) {
449 return true;
450 }
451 }
452 false
453}
454
455pub fn extract_context(command: &str, output: Option<&str>, cwd: &Path) -> GitContextUpdate {
466 let mut update = GitContextUpdate::default();
467
468 update.merge(parse_gh_command(command));
470
471 if let Some(out) = output
475 && should_parse_output_urls(command)
476 {
477 update.merge(parse_github_urls(out));
478 }
479
480 if let Some((path, branch)) = parse_worktree_add(command, cwd) {
482 update.worktree_path = Some(path);
483 update.worktree_branch = branch;
484 }
485
486 if let Some(path) = parse_git_c_path(command, cwd) {
488 update.worktree_path = Some(path);
489 }
490
491 if let Some(path) = parse_cd_path(command, cwd) {
493 update.worktree_path = Some(path);
494 }
495
496 update
497}
498
499pub fn split_chained_commands(command: &str) -> Vec<&str> {
516 let mut parts = Vec::new();
519 let mut current = command;
520
521 while !current.is_empty() {
523 let delim_pos = [
525 current.find("&&").map(|p| (p, 2)),
526 current.find("||").map(|p| (p, 2)),
527 current.find(';').map(|p| (p, 1)),
528 ]
529 .into_iter()
530 .flatten()
531 .min_by_key(|(pos, _)| *pos);
532
533 if let Some((pos, len)) = delim_pos {
534 let part = current[..pos].trim();
535 if !part.is_empty() {
536 parts.push(part);
537 }
538 current = ¤t[pos + len..];
539 } else {
540 let part = current.trim();
541 if !part.is_empty() {
542 parts.push(part);
543 }
544 break;
545 }
546 }
547
548 parts
549}
550
551fn resolve_path(path: &str, cwd: &Path) -> String {
553 let path = if path.starts_with('~') {
554 if let Ok(home) = std::env::var("HOME") {
556 path.replacen('~', &home, 1)
557 } else {
558 path.to_string()
559 }
560 } else {
561 path.to_string()
562 };
563
564 let path = Path::new(&path);
565 if path.is_absolute() {
566 path.to_string_lossy().into_owned()
567 } else {
568 cwd.join(path).to_string_lossy().into_owned()
569 }
570}
571
572#[cfg(test)]
573#[expect(clippy::unwrap_used, reason = "test code uses unwrap for clarity")]
574mod tests {
575 use super::*;
576 use std::path::PathBuf;
577
578 #[test]
579 fn test_parse_gh_issue_view() {
580 let update = parse_gh_command("gh issue view 52");
581 assert_eq!(update.github_issue, Some(52));
582 assert_eq!(update.github_pr, None);
583 assert_eq!(update.github_repo, None);
584 }
585
586 #[test]
587 fn test_parse_gh_issue_with_repo() {
588 let update = parse_gh_command("gh issue view 52 --repo paradigmxyz/mi6");
589 assert_eq!(update.github_issue, Some(52));
590 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
591 }
592
593 #[test]
594 fn test_parse_gh_pr_checks() {
595 let update = parse_gh_command("gh pr checks 86");
596 assert_eq!(update.github_pr, Some(86));
597 assert_eq!(update.github_issue, None);
598 }
599
600 #[test]
601 fn test_parse_gh_pr_with_repo() {
602 let update = parse_gh_command("gh pr view 64 --repo paradigmxyz/mi6");
603 assert_eq!(update.github_pr, Some(64));
604 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
605 }
606
607 #[test]
608 fn test_parse_github_url_issue() {
609 let update = parse_github_urls("Created https://github.com/paradigmxyz/mi6/issues/236");
610 assert_eq!(update.github_issue, Some(236));
611 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
612 }
613
614 #[test]
615 fn test_parse_github_url_pr() {
616 let update = parse_github_urls("Created https://github.com/paradigmxyz/mi6/pull/237");
617 assert_eq!(update.github_pr, Some(237));
618 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
619 }
620
621 #[test]
622 fn test_parse_closes_references() {
623 let issues = parse_closes_references("This PR fixes #52 and closes #53");
624 assert_eq!(issues, vec![52, 53]);
625 }
626
627 #[test]
628 fn test_parse_closes_references_case_insensitive() {
629 let issues = parse_closes_references("FIXES #42\nResolves #43\ncloses #44");
630 assert_eq!(issues, vec![42, 43, 44]);
631 }
632
633 #[test]
634 fn test_parse_gh_pr_create_with_fixes() {
635 let update = parse_gh_command(r#"gh pr create --title "Fix bug" --body "Fixes #52""#);
636 assert_eq!(update.github_issue, Some(52));
637 }
638
639 #[test]
640 fn test_parse_worktree_add_simple() {
641 let cwd = PathBuf::from("/test/repos/mi6");
642 let result = parse_worktree_add("git worktree add ../mi6-issue-42", &cwd);
643 assert!(result.is_some());
644 let (path, branch) = result.unwrap();
645 assert_eq!(path, "/test/repos/mi6/../mi6-issue-42");
646 assert_eq!(branch, None);
647 }
648
649 #[test]
650 fn test_parse_worktree_add_with_branch() {
651 let cwd = PathBuf::from("/test/repos/mi6");
652 let result = parse_worktree_add(
653 "git worktree add ../mi6-fix -b fix/issue-42 origin/main",
654 &cwd,
655 );
656 assert!(result.is_some());
657 let (path, branch) = result.unwrap();
658 assert_eq!(path, "/test/repos/mi6/../mi6-fix");
659 assert_eq!(branch, Some("fix/issue-42".to_string()));
660 }
661
662 #[test]
663 fn test_parse_git_c_path() {
664 let cwd = PathBuf::from("/test/repos");
665 let cmd = "git -C /test/worktree status";
666 let result = parse_git_c_path(cmd, &cwd);
667 assert_eq!(result, Some("/test/worktree".to_string()));
668 }
669
670 #[test]
671 fn test_parse_cd_path_unquoted() {
672 let cwd = PathBuf::from("/test");
673 let cmd = "cd /test/path/to/dir";
674 let result = parse_cd_path(cmd, &cwd);
675 assert_eq!(result, Some("/test/path/to/dir".to_string()));
676 }
677
678 #[test]
679 fn test_parse_cd_path_quoted() {
680 let cwd = PathBuf::from("/test");
681 let cmd = r#"cd "/test/path with spaces/dir""#;
682 let result = parse_cd_path(cmd, &cwd);
683 assert_eq!(result, Some("/test/path with spaces/dir".to_string()));
684 }
685
686 #[test]
687 fn test_parse_cd_path_relative() {
688 let cwd = PathBuf::from("/test/repos");
689 let result = parse_cd_path("cd mi6-issue-42", &cwd);
690 assert_eq!(result, Some("/test/repos/mi6-issue-42".to_string()));
691 }
692
693 #[test]
694 fn test_chained_commands() {
695 let update =
696 parse_gh_command("cargo build && gh pr create --title 'Fix' --body 'Fixes #52'");
697 assert_eq!(update.github_issue, Some(52));
698 }
699
700 #[test]
701 fn test_chained_commands_semicolon() {
702 let update = parse_gh_command("echo hello; gh issue view 42");
703 assert_eq!(update.github_issue, Some(42));
704 }
705
706 #[test]
707 fn test_extract_context_combined() {
708 let cwd = PathBuf::from("/test/repos/mi6");
709 let update = extract_context(
710 "gh pr view 86 --repo paradigmxyz/mi6",
711 Some("Some output here"),
712 &cwd,
713 );
714 assert_eq!(update.github_pr, Some(86));
715 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
716 }
717
718 #[test]
719 fn test_extract_context_from_output() {
720 let cwd = PathBuf::from("/test/repos/mi6");
721 let update = extract_context(
722 "gh pr create --title 'Fix' --body 'Some fix'",
723 Some("Created https://github.com/paradigmxyz/mi6/pull/237"),
724 &cwd,
725 );
726 assert_eq!(update.github_pr, Some(237));
727 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
728 }
729
730 #[test]
731 fn test_session_context_update_has_values() {
732 let empty = GitContextUpdate::default();
733 assert!(!empty.has_values());
734
735 let with_issue = GitContextUpdate {
736 github_issue: Some(42),
737 ..Default::default()
738 };
739 assert!(with_issue.has_values());
740
741 let with_branch = GitContextUpdate {
743 git_branch: Some("feature/test".to_string()),
744 ..Default::default()
745 };
746 assert!(with_branch.has_values());
747 }
748
749 #[test]
750 fn test_session_context_update_merge() {
751 let mut base = GitContextUpdate {
752 github_repo: Some("foo/bar".to_string()),
753 github_issue: Some(1),
754 ..Default::default()
755 };
756
757 let other = GitContextUpdate {
758 github_issue: Some(2),
759 github_pr: Some(42),
760 ..Default::default()
761 };
762
763 base.merge(other);
764
765 assert_eq!(base.github_repo, Some("foo/bar".to_string())); assert_eq!(base.github_issue, Some(2)); assert_eq!(base.github_pr, Some(42)); }
769
770 #[test]
771 fn test_session_context_update_merge_git_branch() {
772 let mut base = GitContextUpdate {
773 github_pr: Some(42),
774 git_branch: Some("main".to_string()),
775 ..Default::default()
776 };
777
778 let other = GitContextUpdate {
779 git_branch: Some("feature/fix".to_string()),
780 ..Default::default()
781 };
782
783 base.merge(other);
784
785 assert_eq!(base.github_pr, Some(42)); assert_eq!(base.git_branch, Some("feature/fix".to_string())); }
788
789 #[test]
790 fn test_should_parse_output_urls() {
791 assert!(should_parse_output_urls("gh pr create --title 'Fix'"));
793 assert!(should_parse_output_urls("gh issue create --title 'Bug'"));
794 assert!(should_parse_output_urls(
795 "cargo build && gh pr create --title 'Fix'"
796 ));
797
798 assert!(!should_parse_output_urls("gh run view 12345"));
800 assert!(!should_parse_output_urls("gh pr view 42"));
801 assert!(!should_parse_output_urls("gh issue view 42"));
802 assert!(!should_parse_output_urls("cat README.md"));
803 assert!(!should_parse_output_urls(
804 "gh run download 12345 --name logs"
805 ));
806 }
807
808 #[test]
809 fn test_extract_context_ignores_urls_in_non_creation_output() {
810 let cwd = PathBuf::from("/test/repos/mi6");
814 let ci_log_output = r#"
815 # GitHub does not enforce `required: true` inputs itself. https://github.com/actions/runner/issues/1070
816 echo "toolchain=$toolchain" >> $GITHUB_OUTPUT
817 "#;
818
819 let update = extract_context("gh run view 12345 --log", Some(ci_log_output), &cwd);
820
821 assert_eq!(update.github_issue, None);
823 assert_eq!(update.github_repo, None);
824 assert_eq!(update.github_pr, None);
825 }
826
827 #[test]
828 fn test_extract_context_issue_create_output() {
829 let cwd = PathBuf::from("/test/repos/mi6");
830 let update = extract_context(
831 "gh issue create --title 'Bug report'",
832 Some("https://github.com/paradigmxyz/mi6/issues/42"),
833 &cwd,
834 );
835 assert_eq!(update.github_issue, Some(42));
836 assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
837 }
838}