claudex 0.11.0

Reusable library for indexing and querying Claude Code, Codex, Copilot, Pi, and OpenClaw coding sessions
Documentation
use std::collections::BTreeSet;

use super::ProviderRecord;

const GITHUB_PR_PREFIX: &str = "https://github.com/";

pub fn append_github_pr_links(
    entry: &mut ProviderRecord,
    seen: &mut BTreeSet<String>,
    text: &str,
    timestamp: Option<&str>,
) {
    for link in extract_github_pr_links(text, timestamp.unwrap_or_default()) {
        if seen.insert(link.1.clone()) {
            entry.pr_links.push(link);
        }
    }
}

pub fn extract_github_pr_links(text: &str, timestamp: &str) -> Vec<(i64, String, String, String)> {
    let mut links = Vec::new();
    let mut start_at = 0usize;
    while let Some(pos) = text[start_at..].find(GITHUB_PR_PREFIX) {
        let absolute = start_at + pos;
        let rest = &text[absolute + GITHUB_PR_PREFIX.len()..];
        if let Some((number, url, repo, consumed)) = parse_pr_path(rest) {
            links.push((number, url, repo, timestamp.to_string()));
            start_at = absolute + GITHUB_PR_PREFIX.len() + consumed;
        } else {
            start_at = absolute + GITHUB_PR_PREFIX.len();
        }
    }
    links
}

pub(crate) fn looks_like_gh_pr_command(text: &str) -> bool {
    if let Ok(value) = serde_json::from_str::<serde_json::Value>(text) {
        return value_looks_like_gh_pr_command(&value, 0);
    }

    let lower = text.to_ascii_lowercase();
    let trimmed = lower.trim();
    if !trimmed.contains("gh pr") || trimmed.contains("\"gh pr\"") || trimmed.contains("'gh pr'") {
        return false;
    }
    !["rg ", "grep ", "sed ", "cat "]
        .iter()
        .any(|prefix| trimmed.starts_with(prefix))
}

fn value_looks_like_gh_pr_command(value: &serde_json::Value, depth: usize) -> bool {
    if depth > 4 {
        return false;
    }
    match value {
        serde_json::Value::String(s) => looks_like_gh_pr_command(s),
        serde_json::Value::Array(items) => {
            let joined = items
                .iter()
                .filter_map(serde_json::Value::as_str)
                .collect::<Vec<_>>()
                .join(" ");
            looks_like_gh_pr_command(&joined)
                || items
                    .iter()
                    .any(|item| value_looks_like_gh_pr_command(item, depth + 1))
        }
        serde_json::Value::Object(map) => {
            ["command", "cmd", "parsed_cmd", "arguments", "args", "input"]
                .iter()
                .any(|field| {
                    map.get(*field)
                        .is_some_and(|v| value_looks_like_gh_pr_command(v, depth + 1))
                })
        }
        _ => false,
    }
}

pub(crate) fn looks_like_final_pr_text(text: &str) -> bool {
    let lower = text.to_ascii_lowercase();
    text.contains("::git-create-pr")
        || (text.contains("https://github.com/") && lower.contains("opened "))
}

fn parse_pr_path(rest: &str) -> Option<(i64, String, String, usize)> {
    let bytes = rest.as_bytes();
    let owner_end = component_end(bytes, 0)?;
    if owner_end == 0 || bytes.get(owner_end) != Some(&b'/') {
        return None;
    }
    let repo_start = owner_end + 1;
    let repo_end = component_end(bytes, repo_start)?;
    if repo_end == repo_start || bytes.get(repo_end) != Some(&b'/') {
        return None;
    }
    let pull = b"pull/";
    let pull_start = repo_end + 1;
    if bytes.len() < pull_start + pull.len() || &bytes[pull_start..pull_start + pull.len()] != pull
    {
        return None;
    }
    let num_start = pull_start + pull.len();
    let num_end = bytes[num_start..]
        .iter()
        .position(|b| !b.is_ascii_digit())
        .map(|p| num_start + p)
        .unwrap_or(bytes.len());
    if num_end == num_start {
        return None;
    }

    let number = rest[num_start..num_end].parse::<i64>().ok()?;
    let owner = &rest[..owner_end];
    let repo_name = &rest[repo_start..repo_end];
    if is_placeholder_repo(owner, repo_name) {
        return None;
    }
    let repo = format!("{owner}/{repo_name}");
    let url = format!("{GITHUB_PR_PREFIX}{repo}/pull/{number}");
    Some((number, url, repo, num_end))
}

fn is_placeholder_repo(owner: &str, repo: &str) -> bool {
    matches!(
        (
            owner.to_ascii_lowercase().as_str(),
            repo.to_ascii_lowercase().as_str()
        ),
        ("org", "repo") | ("owner", "repo") | ("example", "repo")
    )
}

fn component_end(bytes: &[u8], start: usize) -> Option<usize> {
    bytes[start..]
        .iter()
        .position(|b| !is_repo_component_byte(*b))
        .map(|p| start + p)
        .or(Some(bytes.len()))
}

fn is_repo_component_byte(b: u8) -> bool {
    b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn extracts_and_canonicalizes_github_pr_urls() {
        let links = extract_github_pr_links(
            r#"opened "https://github.com/utensils/claudex/pull/38/files"."#,
            "2026-05-30T00:00:00Z",
        );
        assert_eq!(links.len(), 1);
        assert_eq!(links[0].0, 38);
        assert_eq!(links[0].1, "https://github.com/utensils/claudex/pull/38");
        assert_eq!(links[0].2, "utensils/claudex");
        assert_eq!(links[0].3, "2026-05-30T00:00:00Z");
    }

    #[test]
    fn ignores_placeholder_repo_urls() {
        let links = extract_github_pr_links("https://github.com/org/repo/pull/123", "ts");
        assert!(links.is_empty());
    }

    #[test]
    fn gh_pr_command_detection_ignores_search_commands() {
        assert!(looks_like_gh_pr_command("gh pr create --fill"));
        assert!(looks_like_gh_pr_command(
            "gh pr view https://github.com/utensils/claudex/pull/38"
        ));
        assert!(!looks_like_gh_pr_command(
            "rg -n \"gh pr\" ~/.codex/sessions"
        ));
        assert!(!looks_like_gh_pr_command(
            "sed -n '1,20p' docs.md # gh pr view https://github.com/org/repo/pull/123"
        ));
        assert!(looks_like_gh_pr_command(r#"{"cmd":"gh pr create --fill"}"#));
        assert!(!looks_like_gh_pr_command(
            r#"{"cmd":"rg -n \"pull/|gh pr|git-create-pr\" src tests"}"#
        ));
    }
}