use std::io::ErrorKind;
use std::process::Command;
use chrono::{Datelike, Local, NaiveDate};
use serde::Deserialize;
use crate::calendar::{Calendar, Day};
const QUERY: &str = "\
query($login: String!, $from: DateTime!, $to: DateTime!) {
user(login: $login) {
login
contributionsCollection(from: $from, to: $to) {
contributionYears
contributionCalendar {
totalContributions
weeks { contributionDays { date contributionCount contributionLevel } }
}
}
}
}";
const NO_GH: &str = "`gh` (GitHub CLI) was not found on PATH. \
Install it from https://cli.github.com, then run `gh auth login`.";
pub fn fetch(login: &str, year: i32, today: NaiveDate) -> Result<Calendar, String> {
let from = format!("{year}-01-01T00:00:00Z");
let to = format!("{year}-12-31T23:59:59Z");
let body = run_query(&[("login", login), ("from", &from), ("to", &to)])?;
parse(year, &body, Some(today))
}
pub fn from_file(path: &str, now: Option<NaiveDate>) -> Result<Calendar, String> {
let body =
std::fs::read_to_string(path).map_err(|error| format!("could not read {path}: {error}"))?;
parse(
now.map_or_else(|| Local::now().year(), |date| date.year()),
&body,
now,
)
}
pub fn whoami() -> Option<String> {
let out = Command::new("gh")
.args(["api", "user", "--jq", ".login"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let login = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!login.is_empty()).then_some(login)
}
fn run_query(vars: &[(&str, &str)]) -> Result<String, String> {
let mut cmd = Command::new("gh");
cmd.args(["api", "graphql", "-f", &format!("query={QUERY}")]);
for (key, value) in vars {
cmd.arg("-f").arg(format!("{key}={value}"));
}
let out = cmd.output().map_err(|e| match e.kind() {
ErrorKind::NotFound => NO_GH.to_string(),
_ => format!("could not run gh: {e}"),
})?;
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
if stdout.trim().is_empty() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(if stderr.is_empty() {
format!("gh exited with {} and no output", out.status)
} else {
crate::printable(&stderr)
});
}
Ok(stdout)
}
fn parse(fallback_year: i32, body: &str, now: Option<NaiveDate>) -> Result<Calendar, String> {
let resp: Response =
serde_json::from_str(body).map_err(|e| format!("unexpected response from gh: {e}"))?;
if let Some(error) = resp.errors.into_iter().flatten().next() {
let message = error
.message
.unwrap_or_else(|| "GitHub reported an error with no message".to_string());
return Err(crate::printable(&message));
}
let user = resp
.data
.and_then(|d| d.user)
.ok_or_else(|| "GitHub returned no user for that login".to_string())?;
let calendar = &user.contributions.calendar;
let mut days = Vec::with_capacity(371);
let mut unknown: Vec<bool> = Vec::with_capacity(371);
for week in &calendar.weeks {
for raw in &week.days {
let date = NaiveDate::parse_from_str(&raw.date, "%Y-%m-%d")
.map_err(|e| format!("bad date {:?}: {e}", raw.date))?;
days.push(Day {
date,
count: raw.count,
level: level_of(&raw.level).unwrap_or(0),
future: now.is_some_and(|today| date > today),
});
unknown.push(level_of(&raw.level).is_none());
}
}
if unknown.iter().any(|missing| *missing) {
let peak = days.iter().map(|day| day.count).max().unwrap_or(0);
for (day, missing) in days.iter_mut().zip(&unknown) {
if *missing {
day.level = crate::art::level(day.count, peak);
}
}
}
days.sort_unstable_by_key(|d| d.date);
if let Some(pair) = days.windows(2).find(|pair| pair[0].date == pair[1].date) {
return Err(format!(
"that calendar lists {} twice; a day has one record",
pair[0].date
));
}
if let (Some(first), Some(last)) = (days.first(), days.last()) {
let span = (last.date - first.date).num_days();
if span > 400 {
return Err(format!(
"that calendar spans {span} days from {} to {}; a year is at most 366",
first.date, last.date
));
}
for edge in [first, last] {
if !crate::cli::YEARS.contains(&edge.date.year()) {
return Err(format!(
"that calendar has a day in {}, and these tools work in {} to {}",
edge.date.year(),
crate::cli::YEARS.start(),
crate::cli::YEARS.end()
));
}
}
}
let year = days.first().map_or(fallback_year, |day| day.date.year());
Ok(Calendar::build(
crate::printable(&user.login),
year,
calendar.total,
user.contributions.years,
days,
))
}
fn level_of(level: &str) -> Option<u8> {
match level {
"NONE" => Some(0),
"FIRST_QUARTILE" => Some(1),
"SECOND_QUARTILE" => Some(2),
"THIRD_QUARTILE" => Some(3),
"FOURTH_QUARTILE" => Some(4),
_ => None,
}
}
#[derive(Deserialize)]
struct Response {
data: Option<Data>,
errors: Option<Vec<GraphQlError>>,
}
#[derive(Deserialize)]
struct Data {
user: Option<User>,
}
#[derive(Deserialize)]
struct User {
login: String,
#[serde(rename = "contributionsCollection")]
contributions: Contributions,
}
#[derive(Deserialize)]
struct Contributions {
#[serde(rename = "contributionYears")]
years: Vec<i32>,
#[serde(rename = "contributionCalendar")]
calendar: CalendarData,
}
#[derive(Deserialize)]
struct CalendarData {
#[serde(rename = "totalContributions")]
total: u32,
weeks: Vec<RawWeek>,
}
#[derive(Deserialize)]
struct RawWeek {
#[serde(rename = "contributionDays")]
days: Vec<RawDay>,
}
#[derive(Deserialize)]
struct RawDay {
date: String,
#[serde(rename = "contributionCount")]
count: u32,
#[serde(rename = "contributionLevel")]
level: String,
}
#[derive(Deserialize)]
struct GraphQlError {
message: Option<String>,
}