Skip to main content

rootle_github/
client.rs

1//! GitHub client composition. Resource operations live in sibling modules;
2//! only construction and shared session state belong in this file.
3
4mod auth;
5mod blame;
6mod blobs;
7mod history;
8mod refs;
9mod search;
10mod transport;
11mod trees;
12
13use std::collections::HashMap;
14use std::sync::Mutex;
15use std::time::Duration;
16
17const API: &str = "https://api.github.com";
18const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
19const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
20
21pub struct GitHubClient {
22    http: reqwest::blocking::Client,
23    token: Option<String>,
24    /// How the token was resolved (env var name or `gh auth token`) —
25    /// diagnostics record this label, never a value.
26    auth_source: &'static str,
27    blame_cache: Mutex<HashMap<blame::BlameCacheKey, Vec<rootle_provider::BlameRange>>>,
28}
29
30impl Default for GitHubClient {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl GitHubClient {
37    pub fn new() -> Self {
38        let (token, auth_source) = auth::token();
39        Self::build(token, auth_source)
40    }
41
42    /// No environment or credential process; useful for anonymous browsing.
43    pub fn anonymous() -> Self {
44        Self::build(None, "anonymous")
45    }
46
47    fn build(token: Option<String>, auth_source: &'static str) -> Self {
48        let http = reqwest::blocking::Client::builder()
49            .user_agent(concat!("rootle/", env!("CARGO_PKG_VERSION")))
50            .timeout(REQUEST_TIMEOUT)
51            .connect_timeout(CONNECT_TIMEOUT)
52            .build()
53            .expect("construct GitHub HTTP client");
54        Self {
55            http,
56            token,
57            auth_source,
58            blame_cache: Mutex::new(HashMap::new()),
59        }
60    }
61
62    pub fn is_anonymous(&self) -> bool {
63        self.token.is_none()
64    }
65}
66
67/// Percent-encode UTF-8 bytes, not Unicode scalar values. The latter
68/// silently corrupts non-ASCII queries and revision names.
69fn urlencoding(value: &str) -> String {
70    use std::fmt::Write;
71    let mut encoded = String::with_capacity(value.len());
72    for byte in value.bytes() {
73        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~' | b'/') {
74            encoded.push(char::from(byte));
75        } else {
76            write!(&mut encoded, "%{byte:02X}").expect("write to a String");
77        }
78    }
79    encoded
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn query_encoding_preserves_unicode_and_reserved_characters() {
88        assert_eq!(urlencoding("文字 +&"), "%E6%96%87%E5%AD%97%20%2B%26");
89        assert_eq!(urlencoding("owner/repo"), "owner/repo");
90    }
91}