Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,556 changes: 1,031 additions & 525 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
[workspace]
members = [ "checkgit", "checkgit_core","checkgit_tui"]
members = ["checkgit", "checkgit_core"]
8 changes: 8 additions & 0 deletions checkgit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,11 @@ version = "0.1.0"
edition = "2024"

[dependencies]
checkgit_core = { path = "../checkgit_core" }
clap = { version = "4.5.60", features = ["derive"] }
colored = "3.1.1"
dirs = "6.0.0"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
toml = "0.9.8"
viuer = "0.7"
92 changes: 90 additions & 2 deletions checkgit/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,91 @@
fn main() {
println!("Hello, world!");
use checkgit_core::get_user_profile;

mod token_cli;

use clap::Parser;
use colored::*;
use token_cli::*;
use viuer::{print, Config};

#[tokio::main]
async fn main() {
let cli = Cli::parse();

if let Some(Commands::SetToken { token }) = cli.command {
save_token(&token);
return;
}

let username = cli.username.unwrap_or_else(|| {
println!("Please provide username.");
std::process::exit(1);
});

let token = load_token().or_else(|| std::env::var("GITHUB_TOKEN").ok());

if token.is_none() {
print_token_help();
std::process::exit(1);
}

match get_user_profile(&username, token).await {
Ok(profile) => render(profile),
Err(e) => eprintln!("Error: {}", e),
}
}

fn render(profile: checkgit_core::UserProfile) {
print(
&profile.avatar_image,
&Config {
width: Some(40),
..Default::default()
},
)
.unwrap();

println!();

let name = profile
.display_name
.clone()
.unwrap_or(profile.username.clone());

println!("{}", name.bold().bright_white());
println!("{}", format!("@{}", profile.username).bright_black());

println!();
println!("{} {}", "Followers:".bright_blue(), profile.followers);
println!("{} {}", "Following:".bright_blue(), profile.following);
println!("{} {}", "Repos:".bright_blue(), profile.repo_count);
println!("{} {}", "Stars:".bright_blue(), profile.total_stars);

println!();
println!("{}", "Top Repositories".bold().bright_white());

for (name, stars) in profile.top_repos {
println!(" ★ {:<20} {}", name, stars.to_string().yellow());
}

println!();
println!("{}", "Contribution Heatmap".bold().bright_white());
println!();

render_heatmap(profile.contribution_matrix);
}

fn render_heatmap(matrix: Vec<Vec<u32>>) {
for row in matrix {
for value in row {
let block = match value {
0 => " ".on_bright_black(),
1..=2 => " ".on_green(),
3..=5 => " ".on_bright_green(),
6..=10 => " ".on_truecolor(0, 255, 0),
_ => " ".on_truecolor(0, 200, 0),
};
print!("{}", block);
}
println!();
}
}
65 changes: 65 additions & 0 deletions checkgit/src/token_cli.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize};
use std::{fs, path::PathBuf};


#[derive(Parser)]
#[command(name = "checkgit")]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Commands>,
pub username: Option<String>,
}

#[derive(Subcommand)]
pub enum Commands {
SetToken { token: String },
}

#[derive(Serialize, Deserialize)]
pub struct Config {
token: String,
}

pub fn config_path() -> PathBuf {
let mut path = dirs::home_dir().expect("Cannot find home directory");
path.push(".checkgit");

if !path.exists() {
fs::create_dir_all(&path).expect("Failed to create config directory");
}

path.push("config.toml");
path
}

pub fn save_token(token: &str) {
let config = Config {
token: token.to_string(),
};

let toml = toml::to_string(&config).expect("Failed to serialize config");
fs::write(config_path(), toml).expect("Failed to write config file");

println!("Token saved successfully.");
}

pub fn load_token() -> Option<String> {
let path = config_path();

if !path.exists() {
return None;
}

let content = fs::read_to_string(path).ok()?;
let config: Config = toml::from_str(&content).ok()?;
Some(config.token)
}

pub fn print_token_help() {
println!("\nGitHub token not found.\n");
println!("Create one at: https://github.com/settings/tokens");
println!("Scope needed: read:user\n");
println!("Then run:");
println!(" checkgit set-token <your_token>\n");
}
8 changes: 5 additions & 3 deletions checkgit_core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ version = "0.1.0"
edition = "2024"

[dependencies]
reqwest = {version = "*",features = ["json"]}
tokio ={version = "*",features = ["full"]}
serde ={ version = "*",features = ["derive"]}
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
image = "0.24"
36 changes: 36 additions & 0 deletions checkgit_core/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
use std::fmt;

#[derive(Debug)]
pub enum CheckGitError {
Network(reqwest::Error),
UserNotFound,
RateLimited,
Unauthorized,
GithubServerError,
ImageError(String),
InvalidResponse,
}

impl fmt::Display for CheckGitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CheckGitError::Network(e) => write!(f, "Network error: {}", e),
CheckGitError::UserNotFound => write!(f, "GitHub user not found"),
CheckGitError::RateLimited => {
write!(f, "Rate limited. Add GITHUB_TOKEN for higher limits.")
}
CheckGitError::Unauthorized => write!(f, "Unauthorized. Invalid token."),
CheckGitError::GithubServerError => write!(f, "GitHub server error."),
CheckGitError::ImageError(e) => write!(f, "Image processing error: {}", e),
CheckGitError::InvalidResponse => write!(f, "Invalid API response."),
}
}
}

impl std::error::Error for CheckGitError {}

impl From<reqwest::Error> for CheckGitError {
fn from(err: reqwest::Error) -> Self {
CheckGitError::Network(err)
}
}
165 changes: 165 additions & 0 deletions checkgit_core/src/github.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
use reqwest::{Client, StatusCode};
use serde::Deserialize;

use crate::{error::CheckGitError, models::GraphQLResponse};

#[derive(Debug, Deserialize)]
pub struct GithubUserResponse {
pub name: Option<String>,
pub followers: u32,
pub following: u32,
pub avatar_url: String,
pub bio: Option<String>,
pub login: String,
pub public_repos: u32,
}

#[derive(Debug, Deserialize, Clone)]
pub struct GithubRepoResponse {
pub name: String,
pub stargazers_count: u32,
}

pub struct GithubClient {
client: Client,
token: Option<String>,
}

impl GithubClient {
pub fn new(token: Option<String>) -> Result<Self, CheckGitError> {
let client = Client::builder().user_agent("checkgit").build()?;

Ok(Self { client, token })
}

async fn send_request(&self, url: &str) -> Result<reqwest::Response, CheckGitError> {
let response = self.client.get(url).send().await?;
self.handle_status(response).await
}

async fn handle_status(
&self,
response: reqwest::Response,
) -> Result<reqwest::Response, CheckGitError> {
let status = response.status();

match status {
StatusCode::NOT_FOUND => Err(CheckGitError::UserNotFound),
StatusCode::FORBIDDEN => Err(CheckGitError::RateLimited),
StatusCode::UNAUTHORIZED => Err(CheckGitError::Unauthorized),
_ if status.is_server_error() => Err(CheckGitError::GithubServerError),
_ if !status.is_success() => Err(CheckGitError::InvalidResponse),
_ => Ok(response),
}
}

pub async fn fetch_user(&self, username: &str) -> Result<GithubUserResponse, CheckGitError> {
let url = format!("https://api.github.com/users/{}", username);
let response = self.send_request(&url).await?;
Ok(response.json::<GithubUserResponse>().await?)
}

pub async fn fetch_repos(
&self,
username: &str,
) -> Result<Vec<GithubRepoResponse>, CheckGitError> {
let url = format!(
"https://api.github.com/users/{}/repos?per_page=100&sort=stars&direction=desc",
username
);
let response = self.send_request(&url).await?;
Ok(response.json::<Vec<GithubRepoResponse>>().await?)
}

pub async fn fetch_avatar_image(
&self,
avatar_url: &str,
) -> Result<image::DynamicImage, CheckGitError> {
let response = self.client.get(avatar_url).send().await?;
let bytes = response.bytes().await?;

let img = image::load_from_memory(&bytes)
.map_err(|e| CheckGitError::ImageError(e.to_string()))?;

// Center square crop
let size = img.width().min(img.height());
let cropped = img.crop_imm(
(img.width() - size) / 2,
(img.height() - size) / 2,
size,
size,
);

Ok(cropped)
}
pub async fn fetch_contributions(
&self,
username: &str,
) -> Result<Vec<Vec<u32>>, CheckGitError> {
let token = self.token.as_ref().ok_or(CheckGitError::Unauthorized)?;

let query = r#"
query($login: String!) {
user(login: $login) {
contributionsCollection {
contributionCalendar {
weeks {
contributionDays {
contributionCount
}
}
}
}
}
}
"#;

let body = serde_json::json!({
"query": query,
"variables": { "login": username }
});

let response = self
.client
.post("https://api.github.com/graphql")
.bearer_auth(token)
.json(&body)
.send()
.await?;

let response = self.handle_status(response).await?;

let text = response.text().await?;

let parsed: GraphQLResponse =
serde_json::from_str(&text).map_err(|_| CheckGitError::InvalidResponse)?;

if parsed.errors.is_some() || parsed.data.is_none() {
return Err(CheckGitError::InvalidResponse);
}

let weeks = parsed
.data
.unwrap()
.user
.contributions_collection
.contribution_calendar
.weeks;

let mut matrix: Vec<Vec<u32>> = vec![Vec::new(); 7];

for week in weeks {
for (i, day) in week.contribution_days.into_iter().enumerate() {
if i < 7 {
matrix[i].push(day.contribution_count);
}
}
}

Ok(matrix)
}
}

pub fn calculate_total_stars(repos: &[GithubRepoResponse]) -> u32 {
repos.iter().map(|r| r.stargazers_count).sum()
}
Loading