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    blame_cache: Mutex<HashMap<blame::BlameCacheKey, Vec<rootle_provider::BlameRange>>>,
25}
26
27impl Default for GitHubClient {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl GitHubClient {
34    pub fn new() -> Self {
35        Self::build(auth::token())
36    }
37
38    /// No environment or credential process; useful for anonymous browsing.
39    pub fn anonymous() -> Self {
40        Self::build(None)
41    }
42
43    fn build(token: Option<String>) -> Self {
44        let http = reqwest::blocking::Client::builder()
45            .user_agent(concat!("rootle/", env!("CARGO_PKG_VERSION")))
46            .timeout(REQUEST_TIMEOUT)
47            .connect_timeout(CONNECT_TIMEOUT)
48            .build()
49            .expect("construct GitHub HTTP client");
50        Self {
51            http,
52            token,
53            blame_cache: Mutex::new(HashMap::new()),
54        }
55    }
56
57    pub fn is_anonymous(&self) -> bool {
58        self.token.is_none()
59    }
60}
61
62/// Percent-encode UTF-8 bytes, not Unicode scalar values. The latter
63/// silently corrupts non-ASCII queries and revision names.
64fn urlencoding(value: &str) -> String {
65    use std::fmt::Write;
66    let mut encoded = String::with_capacity(value.len());
67    for byte in value.bytes() {
68        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~' | b'/') {
69            encoded.push(char::from(byte));
70        } else {
71            write!(&mut encoded, "%{byte:02X}").expect("write to a String");
72        }
73    }
74    encoded
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn query_encoding_preserves_unicode_and_reserved_characters() {
83        assert_eq!(urlencoding("文字 +&"), "%E6%96%87%E5%AD%97%20%2B%26");
84        assert_eq!(urlencoding("owner/repo"), "owner/repo");
85    }
86}