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}