github_inventions/
lib.rs

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); // Oldest on top.
44    repos.reverse(); // Youngest on top.
45    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) // No borders or dividers.
66        .set_content_arrangement(ContentArrangement::Dynamic)
67        // .set_width(80)
68        .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        // Separator row.
90        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    // html_url: String,
108    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}