Skip to main content

rootle_github/client/
search.rs

1//! Repository, organization and code search.
2
3use super::{API, GitHubClient, urlencoding};
4use crate::types::{OrgRepoItem, SearchReposResponse, SearchUsersResponse};
5use rootle_provider::{ErrorKind, ProviderError, ProviderResult, SearchItem};
6
7impl GitHubClient {
8    /// Repo search + org search, merged: orgs first, then repos.
9    /// Returns provider-level items (the trait boundary type).
10    pub fn search(&self, query: &str) -> ProviderResult<Vec<SearchItem>> {
11        let q = urlencoding(query);
12        let mut out = Vec::new();
13
14        let orgs: SearchUsersResponse =
15            self.get(&format!("{API}/search/users?q={q}+type:org&per_page=5"))?;
16        out.extend(orgs.items.into_iter().map(|u| SearchItem::Org(u.login)));
17
18        let repos: SearchReposResponse =
19            self.get(&format!("{API}/search/repositories?q={q}&per_page=20"))?;
20        out.extend(
21            repos
22                .items
23                .into_iter()
24                .map(|r| SearchItem::Repo(r.full_name)),
25        );
26
27        Ok(out)
28    }
29
30    /// The legacy method name covers explicit owner listing, not just organizations.
31    pub fn org_repos(&self, owner: &str) -> ProviderResult<Vec<rootle_provider::RepoInfo>> {
32        self.owner_repos_at(API, owner)
33    }
34
35    fn owner_repos_at(
36        &self,
37        api: &str,
38        owner: &str,
39    ) -> ProviderResult<Vec<rootle_provider::RepoInfo>> {
40        #[derive(serde::Deserialize)]
41        struct Account {
42            #[serde(rename = "type")]
43            kind: String,
44        }
45        let owner = urlencoding(owner);
46        let account: Account = self.get(&format!("{api}/users/{owner}"))?;
47        let collection = match account.kind.as_str() {
48            "Organization" => "orgs",
49            "User" | "Bot" => "users",
50            _ => {
51                return Err(ProviderError::new(
52                    ErrorKind::Provider,
53                    "unsupported GitHub account type",
54                ));
55            }
56        };
57        let repos: Vec<OrgRepoItem> = self.get(&format!(
58            "{api}/{collection}/{owner}/repos?per_page=100&sort=updated"
59        ))?;
60        Ok(repos
61            .into_iter()
62            .map(|r| rootle_provider::RepoInfo {
63                name: r.name,
64                description: r.description,
65                private: r.private,
66                archived: r.archived,
67                pushed_at: r.pushed_at,
68            })
69            .collect())
70    }
71
72    /// Code search (plans/0002 §4). Requires auth — anonymous clients
73    /// get a clear error. `q` is the full query string including
74    /// qualifiers (`repo:`, `org:`, `extension:`, `path:`); text-match
75    /// fragments are requested for previews.
76    pub fn search_code(&self, q: &str) -> ProviderResult<(Vec<crate::types::CodeItem>, bool)> {
77        let (items, total) = self.search_code_page(q, 1)?;
78        // GitHub caps code search at 1000 results — that is the
79        // provider's own truncation signal (plans/0008 §4).
80        Ok((items, total > 1000))
81    }
82
83    /// One page of code search (`per_page=100`, 1-indexed pages).
84    /// Returns (items, total_count) — total drives `truncated` and the
85    /// progressive page loop (v1.3, plans/0011).
86    pub fn search_code_page(
87        &self,
88        q: &str,
89        page: u32,
90    ) -> ProviderResult<(Vec<crate::types::CodeItem>, u64)> {
91        if self.is_anonymous() {
92            return Err(ProviderError::new(
93                ErrorKind::Auth,
94                "code search needs a token — set ROOTLE_TOKEN or log in with `gh`",
95            ));
96        }
97        let url = format!(
98            "{API}/search/code?q={}&per_page=100&page={page}",
99            urlencoding(q)
100        );
101        let resp: crate::types::SearchCodeResponse =
102            self.get_accept(&url, "application/vnd.github.text-match+json")?;
103        Ok((resp.items, resp.total_count))
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use std::io::{BufRead, BufReader, Write};
111    use std::net::TcpListener;
112
113    fn server(
114        responses: Vec<(&'static str, u16, &'static str)>,
115    ) -> (String, std::thread::JoinHandle<()>) {
116        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
117        let address = format!("http://{}", listener.local_addr().unwrap());
118        let worker = std::thread::spawn(move || {
119            for (path, status, body) in responses {
120                let (mut stream, _) = listener.accept().unwrap();
121                stream
122                    .set_read_timeout(Some(std::time::Duration::from_secs(5)))
123                    .unwrap();
124                let mut reader = BufReader::new(stream.try_clone().unwrap());
125                let mut request = String::new();
126                reader.read_line(&mut request).unwrap();
127                assert_eq!(request.split_whitespace().nth(1), Some(path));
128                loop {
129                    let mut line = String::new();
130                    reader.read_line(&mut line).unwrap();
131                    if line == "\r\n" || line.is_empty() {
132                        break;
133                    }
134                }
135                write!(stream, "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap();
136            }
137        });
138        (address, worker)
139    }
140
141    #[test]
142    fn explicit_owner_listing_uses_account_type_and_preserves_metadata() {
143        for (kind, path) in [
144            (
145                "{\"type\":\"User\"}",
146                "/users/owner/repos?per_page=100&sort=updated",
147            ),
148            (
149                "{\"type\":\"Organization\"}",
150                "/orgs/owner/repos?per_page=100&sort=updated",
151            ),
152        ] {
153            let (api, worker) = server(vec![
154                ("/users/owner", 200, kind),
155                (
156                    path,
157                    200,
158                    "[{\"name\":\"project\",\"private\":true,\"archived\":true,\"description\":\"kept\"}]",
159                ),
160            ]);
161            let repos = GitHubClient::anonymous()
162                .owner_repos_at(&api, "owner")
163                .unwrap();
164            worker.join().unwrap();
165            assert_eq!(
166                repos,
167                vec![rootle_provider::RepoInfo {
168                    name: "project".into(),
169                    private: true,
170                    archived: true,
171                    description: Some("kept".into()),
172                    pushed_at: None,
173                }]
174            );
175        }
176    }
177
178    #[test]
179    fn missing_owner_is_not_retried_as_another_account_type() {
180        let (api, worker) = server(vec![("/users/owner", 404, "{}")]);
181        let error = GitHubClient::anonymous()
182            .owner_repos_at(&api, "owner")
183            .unwrap_err();
184        worker.join().unwrap();
185        assert_eq!(error.kind, ErrorKind::NotFound);
186    }
187
188    #[test]
189    fn listing_auth_error_remains_an_auth_error() {
190        let (api, worker) = server(vec![
191            ("/users/owner", 200, "{\"type\":\"User\"}"),
192            ("/users/owner/repos?per_page=100&sort=updated", 401, "{}"),
193        ]);
194        let error = GitHubClient::anonymous()
195            .owner_repos_at(&api, "owner")
196            .unwrap_err();
197        worker.join().unwrap();
198        assert_eq!(error.kind, ErrorKind::Auth);
199    }
200}