github_rust/
lib.rs

1//! # GitHub Rust - Core Library
2//!
3//! A Rust library for GitHub API integration with dual GraphQL/REST support.
4//!
5//! ## Features
6//!
7//! - **Dual API**: GraphQL primary with REST fallback
8//! - **Search**: Repository search with filters
9//! - **Performance**: Connection pooling and async I/O
10//! - **Type Safety**: Comprehensive error handling
11//!
12//! ## Quick Start
13//!
14//! ```rust,no_run
15//! use github_rust::GitHubService;
16//!
17//! # async fn example() -> github_rust::Result<()> {
18//! let service = GitHubService::new()?;
19//!
20//! // Get repository info
21//! let repo = service.get_repository_info("microsoft", "vscode").await?;
22//! println!("{}: {} stars", repo.name_with_owner, repo.stargazer_count);
23//!
24//! // Search recent repositories (30 days back, limit 10, Rust language, min 100 stars)
25//! let repos = service.search_repositories(30, 10, Some("rust"), 100).await?;
26//! # Ok(())
27//! # }
28//! ```
29//!
30//! ## Authentication
31//!
32//! Set `GITHUB_TOKEN` environment variable for higher rate limits (5000/hour vs 60/hour).
33
34pub mod config;
35pub mod error;
36pub mod github;
37
38pub use config::{GITHUB_API_URL, GITHUB_GRAPHQL_URL};
39pub use error::{GitHubError, Result};
40pub use github::{
41    GitHubClient, GitHubService, RateLimit, Repository, SearchRepository, StargazerWithDate, User,
42    UserProfile,
43};
44
45use base64::Engine;
46
47/// Parses a GitHub GraphQL node ID to extract the numeric repository ID.
48///
49/// GitHub node IDs come in two formats:
50/// - New format: `R_kgDO...` - URL-safe base64-encoded msgpack with structure [type, uint32_id]
51/// - Legacy format: `MDEwOlJlcG9zaXRvcnk...` - standard base64-encoded string "010:Repository{id}"
52///
53/// Returns 0 if parsing fails (should not happen with valid GitHub IDs).
54///
55/// # Examples
56///
57/// ```
58/// use github_rust::parse_github_node_id;
59///
60/// // New format (URL-safe base64 msgpack)
61/// let id = parse_github_node_id("R_kgDOQBnJRQ");
62/// assert_eq!(id, 1075431749);
63///
64/// // Returns 0 for invalid IDs
65/// let invalid = parse_github_node_id("invalid");
66/// assert_eq!(invalid, 0);
67/// ```
68pub fn parse_github_node_id(node_id: &str) -> i64 {
69    // New format: R_kgDO... (URL-safe base64-encoded msgpack)
70    if let Some(b64_part) = node_id.strip_prefix("R_") {
71        // Decode URL-safe base64 (add padding if needed)
72        let padded = match b64_part.len() % 4 {
73            2 => format!("{}==", b64_part),
74            3 => format!("{}=", b64_part),
75            _ => b64_part.to_string(),
76        };
77
78        if let Ok(bytes) = base64::engine::general_purpose::URL_SAFE.decode(&padded) {
79            // msgpack format: 0x92 (array of 2), 0x00 (type), 0xce (uint32), 4 bytes ID
80            if bytes.len() >= 7 && bytes[0] == 0x92 && bytes[2] == 0xce {
81                let id_bytes = &bytes[3..7];
82                return i64::from(u32::from_be_bytes([
83                    id_bytes[0],
84                    id_bytes[1],
85                    id_bytes[2],
86                    id_bytes[3],
87                ]));
88            }
89        }
90    }
91
92    // Legacy format: MDEwOlJlcG9zaXRvcnk... (standard base64 "010:Repository{id}")
93    if node_id.starts_with("MDEw")
94        && let Some(id) = base64::engine::general_purpose::STANDARD
95            .decode(node_id)
96            .ok()
97            .and_then(|bytes| String::from_utf8(bytes).ok())
98            .and_then(|s| s.split(':').next_back().and_then(|n| n.parse::<i64>().ok()))
99    {
100        return id;
101    }
102
103    // Fallback: return 0 for invalid IDs
104    tracing::warn!("Failed to parse GitHub node ID: {}", node_id);
105    0
106}
107
108/// Parse a repository string "owner/repo" into components.
109///
110/// # Examples
111///
112/// ```
113/// use github_rust::parse_repository;
114///
115/// let (owner, repo) = parse_repository("microsoft/vscode").unwrap();
116/// assert_eq!(owner, "microsoft");
117/// assert_eq!(repo, "vscode");
118///
119/// assert!(parse_repository("invalid").is_err());
120/// ```
121pub fn parse_repository(repo: &str) -> std::result::Result<(String, String), String> {
122    let parts: Vec<&str> = repo.split('/').collect();
123    if parts.len() != 2 {
124        return Err(format!(
125            "Invalid repository format '{}'. Use 'owner/repo'",
126            repo
127        ));
128    }
129
130    let owner = parts[0].trim();
131    let name = parts[1].trim();
132
133    if owner.is_empty() || name.is_empty() {
134        return Err(format!(
135            "Invalid repository format '{}'. Owner and repository name cannot be empty",
136            repo
137        ));
138    }
139
140    Ok((owner.to_string(), name.to_string()))
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn test_parse_repository_valid() {
149        let (owner, repo) = parse_repository("owner/repo").unwrap();
150        assert_eq!(owner, "owner");
151        assert_eq!(repo, "repo");
152    }
153
154    #[test]
155    fn test_parse_repository_with_whitespace() {
156        let (owner, repo) = parse_repository(" owner / repo ").unwrap();
157        assert_eq!(owner, "owner");
158        assert_eq!(repo, "repo");
159    }
160
161    #[test]
162    fn test_parse_repository_invalid() {
163        assert!(parse_repository("invalid").is_err());
164        assert!(parse_repository("/repo").is_err());
165        assert!(parse_repository("owner/").is_err());
166        assert!(parse_repository("owner/repo/extra").is_err());
167    }
168
169    #[test]
170    fn test_parse_github_node_id_new_format() {
171        // R_kgDOQBnJRQ = karpathy/nanochat (ID: 1075431749)
172        assert_eq!(parse_github_node_id("R_kgDOQBnJRQ"), 1075431749);
173        // R_kgDOQpc_vg = productdevbook/port-killer (ID: 1117208510)
174        assert_eq!(parse_github_node_id("R_kgDOQpc_vg"), 1117208510);
175        // R_kgDOPQG3Mw = anomalyco/opentui (ID: 1023522611)
176        assert_eq!(parse_github_node_id("R_kgDOPQG3Mw"), 1023522611);
177    }
178
179    #[test]
180    fn test_parse_github_node_id_invalid() {
181        assert_eq!(parse_github_node_id("invalid"), 0);
182        assert_eq!(parse_github_node_id(""), 0);
183        assert_eq!(parse_github_node_id("R_invalid"), 0);
184    }
185}