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    /// Absolute path to git worktree
176    pub worktree_path: Option<String>,
177    /// Branch name in the worktree
178    pub worktree_branch: Option<String>,
179    /// Current git branch (set when capturing PR from gh pr create)
180    pub git_branch: Option<String>,
181}
182
183impl GitContextUpdate {
184    /// Returns true if this update has any values
185    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    /// Merge another update into this one (other values take precedence)
195    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: Use [`GitContextUpdate`] instead.
218#[deprecated(since = "0.4.0", note = "Renamed to GitContextUpdate for clarity")]
219pub type SessionContextUpdate = GitContextUpdate;
220
221// Lazy-compiled regex patterns.
222// These patterns are static literals that are verified at compile time,
223// so expect() is safe here.
224
225#[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    // gh issue/pr {view,checks,diff,merge,close,edit,reopen,comment} N [--repo R]
231    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    // https://github.com/owner/repo/issues/N or /pull/N
242    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    // Closes/Fixes/Resolves #N (case-insensitive)
251    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    // gh pr create ... --body "..." or --body '...'
260    // Capture the body content for scanning
261    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    // git worktree add [-f] <path> [-b <branch>] [<commit>]
270    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    // git -C <path> ...
280    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    // cd <path> with optional quotes
289    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    // gh issue create or gh pr create - commands that output URLs we want to capture
298    Regex::new(r"gh\s+(issue|pr)\s+create").expect("invalid regex")
299});
300
301/// Parse a Bash command for gh issue/pr references.
302///
303/// Handles commands like:
304/// - `gh issue view 52`
305/// - `gh pr checks 86 --repo paradigmxyz/mi6`
306/// - `gh issue close 42`
307pub fn parse_gh_command(command: &str) -> GitContextUpdate {
308    let mut update = GitContextUpdate::default();
309
310    // Split command on && || ; to handle chained commands
311    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        // Also check for gh pr create --body "... Fixes #N ..."
331        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
345/// Parse tool output for GitHub URLs.
346///
347/// Extracts repo, issue, and PR from URLs like:
348/// - `https://github.com/paradigmxyz/mi6/issues/236`
349/// - `https://github.com/paradigmxyz/mi6/pull/237`
350pub 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
374/// Parse PR body text for "Closes #N" references.
375///
376/// Returns a list of issue numbers found.
377pub 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
384/// Parse git worktree add command.
385///
386/// Extracts path and optional branch from commands like:
387/// - `git worktree add ../foo-issue-42`
388/// - `git worktree add ../foo -b fix/issue-42`
389/// - `git worktree add -f /path/to/worktree`
390pub 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            // Resolve path relative to cwd
397            let resolved = resolve_path(path_str, cwd);
398            return Some((resolved, branch));
399        }
400    }
401    None
402}
403
404/// Parse `git -C <path>` from a command.
405///
406/// Returns the path argument if found.
407pub 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
417/// Parse cd command for directory changes.
418///
419/// Handles quoted and unquoted paths:
420/// - `cd /path/to/dir`
421/// - `cd "/path with spaces/dir"`
422/// - `cd '/path/dir'`
423pub 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            // Match groups: 1=double-quoted, 2=single-quoted, 3=unquoted
427            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
438/// Check if the command is one that creates GitHub resources and outputs URLs.
439///
440/// Only these commands should have their output parsed for GitHub URLs:
441/// - `gh issue create` - outputs the created issue URL
442/// - `gh pr create` - outputs the created PR URL
443///
444/// Other commands (like `gh run view`, `cat`, etc.) may have GitHub URLs in their
445/// output, but these are incidental and shouldn't be treated as session context.
446fn 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
455/// Extract all context from a Bash tool event.
456///
457/// Combines command parsing and output parsing to extract:
458/// - GitHub repo, issue, and PR references
459/// - Worktree path and branch
460///
461/// # Arguments
462/// * `command` - The Bash command (from event payload)
463/// * `output` - The tool output (from event tool_output), if any
464/// * `cwd` - Current working directory for resolving relative paths
465pub fn extract_context(command: &str, output: Option<&str>, cwd: &Path) -> GitContextUpdate {
466    let mut update = GitContextUpdate::default();
467
468    // Parse command for gh commands
469    update.merge(parse_gh_command(command));
470
471    // Parse output for GitHub URLs, but only for creation commands.
472    // This prevents incidental URLs (e.g., in CI logs, code comments) from
473    // being treated as session context.
474    if let Some(out) = output
475        && should_parse_output_urls(command)
476    {
477        update.merge(parse_github_urls(out));
478    }
479
480    // Parse worktree operations
481    if let Some((path, branch)) = parse_worktree_add(command, cwd) {
482        update.worktree_path = Some(path);
483        update.worktree_branch = branch;
484    }
485
486    // Parse git -C path (updates worktree_path)
487    if let Some(path) = parse_git_c_path(command, cwd) {
488        update.worktree_path = Some(path);
489    }
490
491    // Parse cd commands
492    if let Some(path) = parse_cd_path(command, cwd) {
493        update.worktree_path = Some(path);
494    }
495
496    update
497}
498
499/// Split command on `&&`, `||`, and `;` to handle chained commands.
500///
501/// Splits on the earliest delimiter found in the string. This is a simple split
502/// that doesn't handle quoted delimiters.
503///
504/// # Examples
505///
506/// ```ignore
507/// use mi6_core::context::github::split_chained_commands;
508///
509/// let parts = split_chained_commands("echo foo && git checkout main");
510/// assert_eq!(parts, vec!["echo foo", "git checkout main"]);
511///
512/// let parts = split_chained_commands("git status; git pull || echo failed");
513/// assert_eq!(parts, vec!["git status", "git pull", "echo failed"]);
514/// ```
515pub fn split_chained_commands(command: &str) -> Vec<&str> {
516    // Split on && || ; but not inside quotes
517    // For simplicity, we do a basic split that handles most cases
518    let mut parts = Vec::new();
519    let mut current = command;
520
521    // Simple split that works for most shell commands
522    while !current.is_empty() {
523        // Find the earliest delimiter (by position, not by type)
524        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 = &current[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
551/// Resolve a path relative to cwd, handling ~ expansion.
552fn resolve_path(path: &str, cwd: &Path) -> String {
553    let path = if path.starts_with('~') {
554        // Expand ~ to home directory
555        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        // git_branch field also triggers has_values
742        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())); // kept
766        assert_eq!(base.github_issue, Some(2)); // overwritten
767        assert_eq!(base.github_pr, Some(42)); // added
768    }
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)); // kept
786        assert_eq!(base.git_branch, Some("feature/fix".to_string())); // overwritten
787    }
788
789    #[test]
790    fn test_should_parse_output_urls() {
791        // Creation commands should have output parsed
792        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        // Non-creation commands should NOT have output parsed
799        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        // This test reproduces a bug where GitHub URLs in CI logs were incorrectly
811        // extracted as session context. The URL below appeared in a comment within
812        // the dtolnay/rust-toolchain action's shell script.
813        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        // Should NOT extract the incidental URL from CI logs
822        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}