1use std::{
2 collections::HashSet,
3 fs,
4 path::{Path, PathBuf},
5};
6
7use anyhow::{anyhow, bail, Context};
8use chrono::{DateTime, Utc};
9
10#[macro_export]
11macro_rules! crate_name {
12 () => {
13 env!("CARGO_PKG_NAME")
14 };
15}
16
17pub fn list(
18 user_name: &str,
19 user_agent: &str,
20 cache: bool,
21 exclude: &[String],
22 description_width: u16,
23 output_file: Option<&Path>,
24) -> anyhow::Result<()> {
25 let mut repos = if cache {
26 let repos =
27 cache_get(user_name)?.ok_or(anyhow!("Cached data not found."))?;
28 tracing::debug!(repo_count = repos.len(), "Retrieved from cache.");
29 repos
30 } else {
31 let repos = fetch(user_name, user_agent)?;
32 tracing::debug!(repo_count = repos.len(), "Fetched from GitHub.");
33 cache_set(user_name, &repos[..])?;
34 repos
35 };
36 repos.retain(|r| !r.fork);
37 tracing::debug!(repo_count = repos.len(), "Removed forks.");
38
39 let exclude: HashSet<String> = exclude.iter().cloned().collect();
40 repos.retain(|r| !exclude.contains(&r.name));
41 tracing::debug!(repo_count = repos.len(), "Removed excluded.");
42
43 repos.sort_by_key(|r| r.created_at); repos.reverse(); tracing::debug!(
46 first = ?repos.first().map(|r| (&r.name, r.created_at)),
47 last = ?repos.last().map(|r| (&r.name, r.created_at)),
48 "Sorted."
49 );
50 output_text_table(&repos[..], description_width, output_file)?;
51 Ok(())
52}
53
54fn output_text_table(
55 repos: &[Repo],
56 description_width: u16,
57 output_file: Option<&Path>,
58) -> anyhow::Result<()> {
59 use comfy_table::{
60 presets, ColumnConstraint, ContentArrangement, Table, Width,
61 };
62
63 let mut table = Table::new();
64 table
65 .load_preset(presets::NOTHING) .set_content_arrangement(ContentArrangement::Dynamic)
67 .set_header(["NAME", "CREATED_ON", "DESCRIPTION"])
69 .set_constraints(vec![
70 ColumnConstraint::ContentWidth,
71 ColumnConstraint::ContentWidth,
72 ColumnConstraint::UpperBoundary(Width::Fixed(description_width)),
73 ]);
74 table.column_iter_mut().for_each(|column| {
75 column.set_padding((0, 2));
76 });
77
78 for Repo {
79 name,
80 created_at,
81 description,
82 ..
83 } in repos
84 {
85 let created_at: String = created_at.date_naive().to_string();
86 let created_at: &str = created_at.as_str();
87 let description: &str = description.as_deref().unwrap_or_default();
88
89 table.add_row(vec![""; 3]);
91
92 table.add_row(vec![name, created_at, description]);
93 }
94 let table = table.to_string();
95 if let Some(output_file) = output_file {
96 fs::write(output_file, &table)?;
97 } else {
98 println!("{table}");
99 }
100 Ok(())
101}
102
103#[derive(serde::Serialize, serde::Deserialize, Debug)]
104struct Repo {
105 name: String,
106 description: Option<String>,
107 created_at: DateTime<Utc>,
109 fork: bool,
110}
111
112fn fetch(user_name: &str, user_agent: &str) -> anyhow::Result<Vec<Repo>> {
113 let mut repos = Vec::new();
114 let mut page: usize = 0;
115 let client = reqwest::blocking::Client::new();
116 loop {
117 page += 1;
118 let url = format!(
119 "https://api.github.com/users/{}/repos?page={}&per_page=25",
120 user_name, page
121 );
122 let resp = client
123 .get(&url)
124 .header("User-Agent", user_agent)
125 .send()
126 .context(format!("Failed to send request to: {url:?}"))?;
127 if !resp.status().is_success() {
128 bail!(
129 "Failed response for request to {url:?}. Response: {resp:?}"
130 )
131 }
132 let body =
133 resp.text().context("Failed to get response body text.")?;
134 let mut batch: Vec<Repo> = serde_json::from_str(&body)
135 .context("Failed to deserialize response body.")?;
136 tracing::debug!(page, size = batch.len(), "Fetched repos batch.");
137 if batch.is_empty() {
138 break;
139 }
140 repos.append(&mut batch);
141 }
142 Ok(repos)
143}
144
145fn cache_get(username: &str) -> anyhow::Result<Option<Vec<Repo>>> {
146 let file_path = cache_file_path(username)?;
147 if file_path.try_exists()? {
148 let data = fs::read_to_string(&file_path)?;
149 let repos = serde_json::from_str(&data)?;
150 Ok(Some(repos))
151 } else {
152 Ok(None)
153 }
154}
155
156fn cache_set(username: &str, repos: &[Repo]) -> anyhow::Result<()> {
157 let file_path = cache_file_path(username)?;
158 if let Some(parent_dir) = file_path.parent() {
159 fs::create_dir_all(parent_dir)
160 .context(format!("Failed to create directory: {parent_dir:?}"))?;
161 }
162 let data = serde_json::to_string_pretty(repos)?;
163 fs::write(file_path, &data)?;
164 Ok(())
165}
166
167fn cache_file_path(username: &str) -> anyhow::Result<PathBuf> {
168 let path = dirs::cache_dir()
169 .ok_or(anyhow!("System cache directory could not be determined."))?
170 .join(crate_name!())
171 .join("repos");
172 let path = path.join(username).with_extension("json");
173 Ok(path)
174}