mi6_core/context/
github.rs

1//! GitHub context extraction from Bash tool events.
2//!
3//! This module parses Bash commands and their output to extract GitHub issue/PR
4//! references and worktree information. This provides more accurate context than
5//! branch name parsing alone, capturing:
6//!
7//! - Explicit `gh issue/pr` commands
8//! - GitHub URLs in command output (e.g., from `gh pr create`)
9//! - `Fixes #N` references in PR bodies
10//! - Git worktree operations
11//!
12//! # Fields Populated
13//!
14//! | Field | Description | Sources |
15//! |-------|-------------|---------|
16//! | `github_issue` | Issue number | `gh` commands, URLs, PR body |
17//! | `github_pr` | PR number | `gh` commands, URLs |
18//! | `github_repo` | Repository (`owner/repo`) | `gh --repo` flag, GitHub URLs |
19//! | `git_worktree_path` | Worktree directory path | `git worktree add`, `git -C`, `cd` |
20//! | `git_branch` | Current branch | Set when capturing PR from `gh pr create` |
21//!
22//! # GitHub CLI Command Parsing
23//!
24//! The [`parse_gh_command`] function parses `gh` CLI commands to extract explicit
25//! issue/PR references.
26//!
27//! ## Supported Commands
28//!
29//! ```text
30//! gh issue {view,checks,diff,merge,close,edit,reopen,comment} <number> [--repo owner/repo]
31//! gh pr {view,checks,diff,merge,close,edit,reopen,comment} <number> [--repo owner/repo]
32//! ```
33//!
34//! **Regex:** `gh\s+(issue|pr)\s+(view|checks|diff|merge|close|edit|reopen|comment)\s+(\d+)(?:.*--repo\s+([^\s]+))?`
35//!
36//! ## PR Body References
37//!
38//! When `gh pr create --body "..."` is detected, the body is scanned for issue references:
39//!
40//! ```text
41//! Closes #N
42//! Fixes #N
43//! Resolves #N
44//! ```
45//!
46//! **Regex (case-insensitive):** `(?i)\b(closes?|fixes?|resolves?)\s*#(\d+)`
47//!
48//! ## Examples
49//!
50//! ```text
51//! gh issue view 52                          -> issue: 52
52//! gh issue view 52 --repo paradigmxyz/mi6   -> issue: 52, repo: paradigmxyz/mi6
53//! gh pr checks 86                           -> pr: 86
54//! gh pr view 64 --repo paradigmxyz/mi6      -> pr: 64, repo: paradigmxyz/mi6
55//! gh pr create --title "Fix" --body "Fixes #52"  -> issue: 52
56//! ```
57//!
58//! # GitHub URL Parsing
59//!
60//! The [`parse_github_urls`] function extracts repo, issue, and PR from GitHub URLs
61//! in command output.
62//!
63//! **Regex:** `https://github\.com/([^/]+/[^/]+)/(issues|pull)/(\d+)`
64//!
65//! ## Examples
66//!
67//! ```text
68//! https://github.com/paradigmxyz/mi6/issues/236  -> issue: 236, repo: paradigmxyz/mi6
69//! https://github.com/paradigmxyz/mi6/pull/237    -> pr: 237, repo: paradigmxyz/mi6
70//! ```
71//!
72//! This is particularly useful for capturing context from `gh pr create` output:
73//! ```text
74//! Created https://github.com/paradigmxyz/mi6/pull/237
75//! ```
76//!
77//! **Note:** URLs are only parsed from output of creation commands (`gh issue create`,
78//! `gh pr create`) to avoid capturing incidental URLs from CI logs or code comments.
79//!
80//! # Worktree and Directory Parsing
81//!
82//! ## Git Worktree Add
83//!
84//! The [`parse_worktree_add`] function extracts path and branch from worktree commands.
85//!
86//! **Regex:** `git\s+worktree\s+add\s+(?:-f\s+)?([^\s]+)(?:.*-b\s+([^\s]+))?`
87//!
88//! ```text
89//! git worktree add ../mi6-issue-42
90//!   -> path: /current/dir/../mi6-issue-42
91//!
92//! git worktree add ../mi6-fix -b fix/issue-42 origin/main
93//!   -> path: /current/dir/../mi6-fix
94//!   -> branch: fix/issue-42
95//! ```
96//!
97//! ## Git -C Path
98//!
99//! The [`parse_git_c_path`] function extracts the path from `git -C <path>` commands.
100//!
101//! **Regex:** `git\s+-C\s+([^\s]+)`
102//!
103//! ## CD Command
104//!
105//! The [`parse_cd_path`] function parses cd commands with quoted/unquoted paths.
106//!
107//! **Regex:** `cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))`
108//!
109//! ## Path Resolution
110//!
111//! All paths are resolved:
112//! - `~` is expanded to `$HOME`
113//! - Relative paths are joined with the session's `cwd`
114//!
115//! # Update Semantics
116//!
117//! Updates use SQL `COALESCE` semantics:
118//! - New non-NULL values overwrite existing values
119//! - NULL values preserve existing values
120//! - "Most recent wins" for non-NULL values
121//!
122//! # Chained Command Support
123//!
124//! Commands are split on `&&`, `||`, and `;` to detect references in compound commands:
125//!
126//! ```text
127//! echo foo && gh issue view 42   -> detected
128//! git status; gh pr view 86      -> detected
129//! ```
130//!
131//! # Limitations
132//!
133//! 1. **Chained commands** - Simple split on delimiters; doesn't handle quoted delimiters
134//! 2. **Path resolution** - Doesn't canonicalize paths (may contain `..`)
135//! 3. **Tool output truncation** - Very long output may be truncated before URL parsing
136//!
137//! # Usage
138//!
139//! ```rust,ignore
140//! use mi6_core::github::{extract_context, GitContextUpdate};
141//! use std::path::Path;
142//!
143//! let cwd = Path::new("/home/user/project");
144//! let update = extract_context(
145//!     "gh issue view 52 --repo paradigmxyz/mi6",
146//!     None,
147//!     cwd,
148//! );
149//!
150//! assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
151//! assert_eq!(update.github_issue, Some(52));
152//! ```
153
154use std::path::Path;
155use std::sync::LazyLock;
156
157use regex::Regex;
158
159/// Git and GitHub context update derived from a Bash tool event.
160///
161/// This struct captures both git-related context (branch, worktree) and
162/// GitHub-specific context (repo, issue, PR) extracted from shell commands
163/// and their output.
164///
165/// Each field is `Some` only if the parsing found a new value.
166/// Use with COALESCE semantics to update session fields.
167#[derive(Debug, Default, Clone, PartialEq, Eq)]
168pub struct GitContextUpdate {
169    /// GitHub repository in `owner/repo` format
170    pub github_repo: Option<String>,
171    /// GitHub issue number
172    pub github_issue: Option<i32>,
173    /// GitHub PR number
174    pub github_pr: Option<i32>,
175    /// Lines added (from git diff --shortstat or gh pr view)
176    pub git_additions: Option<i32>,
177    /// Lines deleted (from git diff --shortstat or gh pr view)
178    pub git_deletions: Option<i32>,
179    /// Absolute path to git worktree
180    pub worktree_path: Option<String>,
181    /// Branch name in the worktree
182    pub worktree_branch: Option<String>,
183    /// Current git branch (set when capturing PR from gh pr create)
184    pub git_branch: Option<String>,
185}
186
187impl GitContextUpdate {
188    /// Returns true if this update has any values
189    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    /// Merge another update into this one (other values take precedence)
201    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// Lazy-compiled regex patterns.
230// These patterns are static literals that are verified at compile time,
231// so expect() is safe here.
232
233#[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    // gh issue/pr {view,checks,diff,merge,close,edit,reopen,comment} N [--repo R]
239    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    // https://github.com/owner/repo/issues/N or /pull/N
250    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    // Closes/Fixes/Resolves #N (case-insensitive)
259    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    // gh pr create ... --body "..." or --body '...'
268    // Capture the body content for scanning
269    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    // git worktree add [-f] <path> [-b <branch>] [<commit>]
278    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    // git -C <path> ...
288    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    // cd <path> with optional quotes
297    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    // gh issue create or gh pr create - commands that output URLs we want to capture
306    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    // gh pr view output: "additions:	148" or "additions:  148"
315    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    // gh pr view output: "deletions:	7" or "deletions:  7"
324    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    // Match "gh pr view" command
333    Regex::new(r"gh\s+pr\s+view\b").expect("invalid regex")
334});
335
336/// Parse a Bash command for gh issue/pr references.
337///
338/// Handles commands like:
339/// - `gh issue view 52`
340/// - `gh pr checks 86 --repo paradigmxyz/mi6`
341/// - `gh issue close 42`
342pub fn parse_gh_command(command: &str) -> GitContextUpdate {
343    let mut update = GitContextUpdate::default();
344
345    // Split command on && || ; to handle chained commands
346    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        // Also check for gh pr create --body "... Fixes #N ..."
366        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
380/// Parse tool output for GitHub URLs.
381///
382/// Extracts repo, issue, and PR from URLs like:
383/// - `https://github.com/paradigmxyz/mi6/issues/236`
384/// - `https://github.com/paradigmxyz/mi6/pull/237`
385pub 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
409/// Parse PR body text for "Closes #N" references.
410///
411/// Returns a list of issue numbers found.
412pub 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
419/// Parse git worktree add command.
420///
421/// Extracts path and optional branch from commands like:
422/// - `git worktree add ../foo-issue-42`
423/// - `git worktree add ../foo -b fix/issue-42`
424/// - `git worktree add -f /path/to/worktree`
425pub 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            // Resolve path relative to cwd
432            let resolved = resolve_path(path_str, cwd);
433            return Some((resolved, branch));
434        }
435    }
436    None
437}
438
439/// Parse `git -C <path>` from a command.
440///
441/// Returns the path argument if found.
442pub 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
452/// Parse cd command for directory changes.
453///
454/// Handles quoted and unquoted paths:
455/// - `cd /path/to/dir`
456/// - `cd "/path with spaces/dir"`
457/// - `cd '/path/dir'`
458pub 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            // Match groups: 1=double-quoted, 2=single-quoted, 3=unquoted
462            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
473/// Parse gh pr view output for additions/deletions.
474///
475/// Handles output containing:
476/// ```text
477/// additions:  148
478/// deletions:  7
479/// ```
480pub 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
500/// Check if the command is a gh pr view command.
501///
502/// These commands output additions/deletions stats.
503pub 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
512/// Check if the command is one that creates GitHub resources and outputs URLs.
513///
514/// Only these commands should have their output parsed for GitHub URLs:
515/// - `gh issue create` - outputs the created issue URL
516/// - `gh pr create` - outputs the created PR URL
517///
518/// Other commands (like `gh run view`, `cat`, etc.) may have GitHub URLs in their
519/// output, but these are incidental and shouldn't be treated as session context.
520fn 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
529/// Extract all context from a Bash tool event.
530///
531/// Combines command parsing and output parsing to extract:
532/// - GitHub repo, issue, and PR references
533/// - Worktree path and branch
534/// - Diff statistics (additions/deletions)
535///
536/// # Arguments
537/// * `command` - The Bash command (from event payload)
538/// * `output` - The tool output (from event tool_output), if any
539/// * `cwd` - Current working directory for resolving relative paths
540pub fn extract_context(command: &str, output: Option<&str>, cwd: &Path) -> GitContextUpdate {
541    let mut update = GitContextUpdate::default();
542
543    // Parse command for gh commands
544    update.merge(parse_gh_command(command));
545
546    // Parse output for GitHub URLs, but only for creation commands.
547    // This prevents incidental URLs (e.g., in CI logs, code comments) from
548    // being treated as session context.
549    if let Some(out) = output
550        && should_parse_output_urls(command)
551    {
552        update.merge(parse_github_urls(out));
553    }
554
555    // Parse gh pr view output for additions/deletions
556    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    // Parse worktree operations
563    if let Some((path, branch)) = parse_worktree_add(command, cwd) {
564        update.worktree_path = Some(path);
565        update.worktree_branch = branch;
566    }
567
568    // Parse git -C path (updates worktree_path)
569    if let Some(path) = parse_git_c_path(command, cwd) {
570        update.worktree_path = Some(path);
571    }
572
573    // Parse cd commands
574    if let Some(path) = parse_cd_path(command, cwd) {
575        update.worktree_path = Some(path);
576    }
577
578    update
579}
580
581/// Split command on `&&`, `||`, and `;` to handle chained commands.
582///
583/// Splits on the earliest delimiter found in the string. This is a simple split
584/// that doesn't handle quoted delimiters.
585///
586/// # Examples
587///
588/// ```ignore
589/// use mi6_core::context::github::split_chained_commands;
590///
591/// let parts = split_chained_commands("echo foo && git checkout main");
592/// assert_eq!(parts, vec!["echo foo", "git checkout main"]);
593///
594/// let parts = split_chained_commands("git status; git pull || echo failed");
595/// assert_eq!(parts, vec!["git status", "git pull", "echo failed"]);
596/// ```
597pub fn split_chained_commands(command: &str) -> Vec<&str> {
598    // Split on && || ; but not inside quotes
599    // For simplicity, we do a basic split that handles most cases
600    let mut parts = Vec::new();
601    let mut current = command;
602
603    // Simple split that works for most shell commands
604    while !current.is_empty() {
605        // Find the earliest delimiter (by position, not by type)
606        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 = &current[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
633/// Resolve a path relative to cwd, handling ~ expansion.
634fn resolve_path(path: &str, cwd: &Path) -> String {
635    let path = if path.starts_with('~') {
636        // Expand ~ to home directory
637        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        // git_branch field also triggers has_values
824        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())); // kept
848        assert_eq!(base.github_issue, Some(2)); // overwritten
849        assert_eq!(base.github_pr, Some(42)); // added
850    }
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)); // kept
868        assert_eq!(base.git_branch, Some("feature/fix".to_string())); // overwritten
869    }
870
871    #[test]
872    fn test_should_parse_output_urls() {
873        // Creation commands should have output parsed
874        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        // Non-creation commands should NOT have output parsed
881        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        // This test reproduces a bug where GitHub URLs in CI logs were incorrectly
893        // extracted as session context. The URL below appeared in a comment within
894        // the dtolnay/rust-toolchain action's shell script.
895        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        // Should NOT extract the incidental URL from CI logs
904        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}