use std::path::PathBuf;
use clap::Parser;
fn parse_date_filter(s: &str) -> anyhow::Result<String> {
let s = s.trim();
if s.len() >= 10 && s[..4].parse::<u32>().is_ok() && s.as_bytes().get(4) == Some(&b'-') {
return Ok(s[..10].to_string());
}
let lower = s.to_lowercase();
let days: u64 = if lower == "today" {
0
} else if lower == "yesterday" {
1
} else if lower == "last week" || lower == "this week" {
7
} else if lower == "last month" || lower == "this month" {
30
} else if lower == "last year" || lower == "this year" {
365
} else if let Some(n) = lower
.strip_suffix(" days ago")
.or_else(|| lower.strip_suffix(" day ago"))
{
n.trim()
.parse::<u64>()
.map_err(|_| anyhow::anyhow!("invalid date: {s:?}"))?
} else if let Some(n) = lower
.strip_suffix(" weeks ago")
.or_else(|| lower.strip_suffix(" week ago"))
{
n.trim()
.parse::<u64>()
.map_err(|_| anyhow::anyhow!("invalid date: {s:?}"))?
* 7
} else if let Some(n) = lower
.strip_suffix(" months ago")
.or_else(|| lower.strip_suffix(" month ago"))
{
n.trim()
.parse::<u64>()
.map_err(|_| anyhow::anyhow!("invalid date: {s:?}"))?
* 30
} else if let Some(n) = lower
.strip_suffix(" years ago")
.or_else(|| lower.strip_suffix(" year ago"))
{
n.trim()
.parse::<u64>()
.map_err(|_| anyhow::anyhow!("invalid date: {s:?}"))?
* 365
} else {
anyhow::bail!(
"unrecognized date: {s:?}\n\
Accepted:\n\
· ISO date: 2024-01-15\n\
· Keywords: today · yesterday\n\
· Named: last week · last month · last year\n\
· Relative: 30 days ago · 2 weeks ago · 1 month ago · 1 year ago"
);
};
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| anyhow::anyhow!(e))?
.as_secs()
.saturating_sub(days * 86_400);
Ok(unix_secs_to_date(secs))
}
fn unix_secs_to_date(secs: u64) -> String {
let mut days = secs / 86_400;
let mut year = 1970u32;
loop {
let in_year = if is_leap_year(year) { 366 } else { 365 };
if days < in_year {
break;
}
days -= in_year;
year += 1;
}
let month_lengths = if is_leap_year(year) {
[31u64, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31u64, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 1u32;
for &ml in &month_lengths {
if days < ml {
break;
}
days -= ml;
month += 1;
}
let day = days + 1;
format!("{year:04}-{month:02}-{day:02}")
}
fn is_leap_year(y: u32) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}
#[tokio::main]
async fn main() {
let args = gitprint::cli::Args::parse();
if args.list_themes {
gitprint::highlight::list_themes()
.iter()
.for_each(|t| println!(" {t}"));
return;
}
if let Some(username) = args.user {
let output_path = args
.output
.unwrap_or_else(|| PathBuf::from(format!("{username}.pdf")));
let since = match args.since.as_deref().map(parse_date_filter) {
Some(Err(e)) => {
eprintln!("error: --since: {e}");
std::process::exit(1);
}
other => other.and_then(Result::ok),
};
let until = match args.until.as_deref().map(parse_date_filter) {
Some(Err(e)) => {
eprintln!("error: --until: {e}");
std::process::exit(1);
}
other => other.and_then(Result::ok),
};
let config = gitprint::types::UserReportConfig {
output_path,
paper_size: args.paper_size,
landscape: args.landscape,
last_repos: args.last_repos,
last_commits: args.last_commits,
no_diffs: args.no_diffs,
font_size: args.font_size,
github_token: std::env::var("GITHUB_TOKEN").ok(),
since,
until,
activity: args.activity,
events: args.events,
username,
};
let result = if args.preview {
gitprint::preview::user(&config).await
} else {
gitprint::user_report::run(&config).await
};
if let Err(e) = result {
eprintln!("error: {e:#}");
std::process::exit(1);
}
return;
}
let path = match args.path {
Some(p) => p,
None => {
eprintln!("error: a path or -u/--user is required");
std::process::exit(1);
}
};
let is_remote = gitprint::git::is_remote_url(&path);
let temp_dir = if is_remote {
eprintln!("Cloning {path}...");
match gitprint::git::TempCloneDir::new().await {
Ok(t) => {
if let Err(e) = gitprint::git::clone_repo(
&path,
t.path(),
args.branch.as_deref(),
args.commit.as_deref(),
)
.await
{
eprintln!("error: {e}");
std::process::exit(1);
}
Some(t)
}
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
} else {
None
};
let repo_path = temp_dir
.as_ref()
.map(|t| t.path().to_path_buf())
.unwrap_or_else(|| PathBuf::from(&path));
if is_remote && args.list_tags {
if let Err(e) = gitprint::git::fetch_tags(&repo_path).await {
eprintln!("warning: could not fetch tags: {e}");
}
}
if args.list_tags {
let tags = gitprint::git::list_repo_tags(&repo_path).await;
if tags.is_empty() {
eprintln!("No tags found.");
} else {
tags.iter().for_each(|t| println!("{t}"));
}
return;
}
let output_path = args.output.unwrap_or_else(|| {
let name = if is_remote {
gitprint::git::repo_name_from_url(&path)
} else {
PathBuf::from(&path)
.canonicalize()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| "output".to_string())
};
PathBuf::from(format!("{name}.pdf"))
});
let config = gitprint::types::Config {
repo_path,
output_path,
include_patterns: args.include,
exclude_patterns: args.exclude,
theme: args.theme,
font_size: args.font_size,
no_line_numbers: args.no_line_numbers,
toc: !args.no_toc,
file_tree: !args.no_file_tree,
branch: args.branch,
commit: args.commit,
paper_size: args.paper_size,
landscape: args.landscape,
remote_url: is_remote.then(|| path.clone()),
};
let result = if args.preview {
gitprint::preview::repo(&config).await
} else {
gitprint::run(&config).await
};
if let Err(e) = result {
eprintln!("error: {e}");
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_iso_date() -> anyhow::Result<()> {
assert_eq!(parse_date_filter("2024-01-15")?, "2024-01-15");
assert_eq!(parse_date_filter("2024-06-30T12:00:00Z")?, "2024-06-30");
Ok(())
}
#[test]
fn parse_relative_dates() -> anyhow::Result<()> {
let today = parse_date_filter("today")?;
assert_eq!(today.len(), 10);
assert!(today.starts_with("20"));
let yesterday = parse_date_filter("yesterday")?;
assert!(yesterday <= today);
Ok(())
}
#[test]
fn parse_n_days_ago() -> anyhow::Result<()> {
let d = parse_date_filter("30 days ago")?;
assert_eq!(d.len(), 10);
let today = parse_date_filter("today")?;
assert!(d <= today);
Ok(())
}
#[test]
fn parse_n_weeks_ago() -> anyhow::Result<()> {
let d = parse_date_filter("2 weeks ago")?;
assert_eq!(d.len(), 10);
Ok(())
}
#[test]
fn parse_n_months_ago() -> anyhow::Result<()> {
let d = parse_date_filter("1 month ago")?;
assert_eq!(d.len(), 10);
Ok(())
}
#[test]
fn parse_n_years_ago() -> anyhow::Result<()> {
let d = parse_date_filter("1 year ago")?;
assert_eq!(d.len(), 10);
let d2 = parse_date_filter("2 years ago")?;
assert_eq!(d2.len(), 10);
assert!(d2 <= d);
Ok(())
}
#[test]
fn parse_named_aliases() -> anyhow::Result<()> {
let today = parse_date_filter("today")?;
let last_week = parse_date_filter("last week")?;
let this_week = parse_date_filter("this week")?;
assert_eq!(last_week, this_week);
assert!(last_week <= today);
let last_month = parse_date_filter("last month")?;
let this_month = parse_date_filter("this month")?;
assert_eq!(last_month, this_month);
assert!(last_month <= last_week);
let last_year = parse_date_filter("last year")?;
let this_year = parse_date_filter("this year")?;
assert_eq!(last_year, this_year);
assert!(last_year <= last_month);
Ok(())
}
#[test]
fn parse_invalid_date_errors() {
assert!(parse_date_filter("not a date").is_err());
assert!(parse_date_filter("abc days ago").is_err());
}
#[test]
fn unix_secs_known_dates() {
assert_eq!(unix_secs_to_date(1_704_067_200), "2024-01-01");
assert_eq!(unix_secs_to_date(951_868_800), "2000-03-01");
}
}