1#[macro_use]
2extern crate lazy_static;
3pub use crate::request::BasicAuth;
4use bytesize::ByteSize;
5use futures::future::join_all as join_all_futures;
6use futures::{FutureExt, TryFutureExt};
7use itertools::Itertools;
8use log::{error, info};
9use std::fmt::Write;
10use std::fs;
11use std::path::PathBuf;
12use std::{future::Future, sync::atomic::Ordering, time::Instant};
13use tera::{Context, Tera};
14
15mod api;
16mod request;
17
18pub use crate::api::*;
19
20pub type Error = Box<dyn std::error::Error>;
21
22fn filter_repos(repos: &Vec<Repo>, user_login: &str, is_user: bool) -> Vec<usize> {
23 let compare_username_matches = |want: bool, user: String| {
24 move |r: &Repo| {
25 if r.owner.login.eq(&user) == want {
26 Some(r.stargazers_count)
27 } else {
28 None
29 }
30 }
31 };
32
33 repos
34 .iter()
35 .filter_map(compare_username_matches(is_user, user_login.to_owned()))
36 .collect()
37}
38
39pub async fn count_stars(
40 username: &str,
41 no_orgs: bool,
42 auth: Option<BasicAuth>,
43 page_size: usize,
44) -> Result<Response, Error> {
45 let fetch_repos_for_user = |user| {
46 fetch_repos(user, page_size, |user, page_number| {
47 let repos_paged_url = format!(
48 "users/{}/repos?per_page={}&page={}",
49 user.login,
50 page_size,
51 page_number + 1
52 );
53 request::json_log_failure(repos_paged_url, auth.clone())
54 })
55 .map_err(|e| {
56 error!("Could not fetch repositories: {}", e);
57 e
58 })
59 };
60 let flatten_into_vec = |vec: Vec<_>| vec.into_iter().flatten().flatten().collect::<Vec<_>>();
61
62 let user_url = format!("users/{}", username);
63 let user: User = request::json(user_url.clone(), auth.clone()).await?;
64 let orgs_url = format!("{}/orgs", user_url);
65 let mut user_repos_futures = vec![fetch_repos_for_user(user.clone()).boxed_local()];
66
67 if !no_orgs {
68 let auth = auth.clone();
69 let orgs_repos_future = async move {
70 let orgs: Vec<RepoOwner> = request::json_log_failure(orgs_url, auth.clone())
71 .await
72 .unwrap_or_else(|_| Vec::new());
73
74 let repos_of_orgs = flatten_into_vec(
75 join_all_futures(orgs.into_iter().map(|user| {
76 request::json_log_failure::<User>(format!("users/{}", user.login), auth.clone())
77 .and_then(fetch_repos_for_user)
78 }))
79 .await,
80 );
81 Ok(repos_of_orgs)
82 }
83 .boxed_local();
84 user_repos_futures.push(orgs_repos_future);
85 };
86
87 let start = Instant::now();
88 let repos = flatten_into_vec(join_all_futures(user_repos_futures).await);
89
90 let elapsed = start.elapsed();
91 let duration_in_network_requests = request::TOTAL_DURATION.lock().unwrap().as_secs_f32();
92 info!(
93 "Total bytes received in body: {}",
94 ByteSize(request::TOTAL_BYTES_RECEIVED_IN_BODY.load(Ordering::Relaxed))
95 );
96 info!(
97 "Total time spent in network requests: {:.2}s",
98 duration_in_network_requests
99 );
100 info!(
101 "Wallclock time for future processing: {:.2}s",
102 elapsed.as_secs_f32()
103 );
104 info!(
105 "Speedup due to networking concurrency: {:.2}x",
106 duration_in_network_requests / elapsed.as_secs_f32()
107 );
108
109 Ok(Response { user, repos })
110}
111
112async fn fetch_repos<F>(
113 user: User, page_size: usize,
115 mut fetch_page: impl FnMut(User, usize) -> F, ) -> Result<Vec<Repo>, Error>
117where
118 F: Future<Output = Result<Vec<Repo>, Error>>,
119{
120 if page_size == 0 {
121 return Err("PageSize must be greater than 0".into());
122 }
123 let page_count = user.public_repos / page_size;
124 let page_futures = (0..=page_count).map(|page_number| fetch_page(user.clone(), page_number));
125 let results = join_all_futures(page_futures).await;
126 let pages_with_results: Vec<Vec<Repo>> = results
127 .into_iter()
128 .collect::<Result<Vec<Vec<_>>, Error>>()?
129 .into_iter()
130 .collect();
131
132 sanity_check(page_size, &pages_with_results);
133 Ok(pages_with_results.into_iter().concat())
134}
135
136#[cfg(test)]
137fn sanity_check(_page_size: usize, _pages_with_results: &Vec<Vec<Repo>>) {}
138
139#[cfg(not(test))]
140fn sanity_check(page_size: usize, pages_with_results: &Vec<Vec<Repo>>) {
141 if pages_with_results.len() > 0 {
142 if let Some(v) = pages_with_results
143 .iter()
144 .take(
145 pages_with_results
146 .len()
147 .checked_sub(1)
148 .expect("more than one page"),
149 )
150 .filter(|v| v.len() != page_size)
151 .next()
152 {
153 panic!(
154 "Asked for {} repos per page, but got only {} in a page which wasn't the last one. --page-size should probably be {}",
155 page_size,
156 v.len(),
157 v.len()
158 );
159 }
160 }
161}
162
163fn get_stats(repos: &Vec<Repo>, login: &str) -> RepoStats {
164 let total: usize = repos.iter().map(|r| r.stargazers_count).sum();
165 let total_by_user_only = filter_repos(&repos, login, true);
166 let total_by_orgs_only = filter_repos(&repos, login, false);
167
168 RepoStats {
169 total,
170 total_by_user_only,
171 total_by_orgs_only,
172 }
173}
174
175pub fn render_output(
176 template: Option<PathBuf>,
177 mut repos: Vec<Repo>,
178 login: String,
179 repo_limit: usize,
180 stargazer_threshold: usize,
181) -> Result<String, Error> {
182 let stats = get_stats(&repos, &login);
183
184 repos.sort_by(|a, b| b.stargazers_count.cmp(&a.stargazers_count));
185 let mut repos: Vec<_> = repos
186 .into_iter()
187 .filter(|r| r.stargazers_count >= stargazer_threshold)
188 .take(repo_limit)
189 .collect();
190
191 if !stats.total_by_orgs_only.is_empty() {
192 for mut repo in repos.iter_mut() {
193 repo.name = format!("{}/{}", repo.owner.login, repo.name);
194 }
195 }
196
197 match template {
198 Some(template) => template_output(repos, stats, login, template),
199 None => default_output(repos, stats, login),
200 }
201}
202
203pub fn template_output(
204 repos: Vec<Repo>,
205 stats: RepoStats,
206 login: String,
207 template: PathBuf,
208) -> Result<String, Error> {
209 let mut context = Context::new();
210 context.insert("repos", &repos);
211 context.insert("total", &stats.total);
212 context.insert("total_by_user_only", &stats.total_by_user_only);
213 context.insert("total_by_orgs_only", &stats.total_by_orgs_only);
214 context.insert("login", &login);
215
216 let template: String = fs::read_to_string(template)?;
217 let rendered = Tera::one_off(&template, &context, true)?;
218 Ok(rendered)
219}
220
221pub fn default_output(repos: Vec<Repo>, stats: RepoStats, login: String) -> Result<String, Error> {
222 let mut out = String::new();
223 writeln!(out, "Total: {}", stats.total)?;
224 if !stats.total_by_user_only.is_empty() && !stats.total_by_orgs_only.is_empty() {
225 writeln!(
226 out,
227 "Total for {}: {}",
228 login,
229 stats.total_by_user_only.iter().sum::<usize>()
230 )?;
231 }
232 if !stats.total_by_orgs_only.is_empty() {
233 writeln!(
234 out,
235 "Total for orgs: {}",
236 stats.total_by_orgs_only.iter().sum::<usize>()
237 )?;
238 }
239
240 if repos.len() > 0 {
241 writeln!(out)?;
242 }
243
244 let max_width = repos.iter().map(|r| r.name.len()).max().unwrap_or(0);
245 for repo in repos {
246 writeln!(
247 out,
248 "{:width$} ★ {}",
249 repo.name,
250 repo.stargazers_count,
251 width = max_width
252 )?;
253 }
254 Ok(out)
255}
256
257#[cfg(test)]
258mod tests;