use std::fs::{metadata, read_dir};
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use anyhow::{bail, Result};
use chrono::{DateTime, NaiveDateTime, Utc};
use clap::{Parser, Subcommand};
use git2::{BlameHunk, ErrorCode, Repository, Time};
use substring::Substring;
use terminal_size::{terminal_size, Height, Width};
use std::println as d;
/// A CLI tool for code age
#[derive(Debug, Parser)]
#[command(name = "age")]
#[command(about = "view history of your git project", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
const BLAME_ID_WIDTH: usize = 16;
#[derive(Debug, Subcommand)]
enum Commands {
/// Views a file or folder
#[command(arg_required_else_help = true)]
View {
/// The file or folder to view
file: String,
},
}
fn display_time(time: Time) -> String {
let dt: DateTime<Utc> = DateTime::from_local(
NaiveDateTime::from_timestamp_opt(time.seconds(), 0).unwrap(),
Utc,
);
dt.to_rfc2822()
}
fn resolve_filepath(path: &String) -> Result<PathBuf> {
match PathBuf::from(&path).canonicalize() {
Ok(x) => Ok(x),
Err(_) => bail!("oh no could not resolve filepath"),
}
}
fn resolve_relative_filepath(path: &String, parent: &String) -> Result<String> {
let file_path = resolve_filepath(&path)
.unwrap()
.into_os_string()
.into_string()
.unwrap()
.replace(parent, "");
return Ok(file_path);
}
fn find_git_root(path: &String) -> Option<(Repository, String)> {
if let Ok(repo) = Repository::open(&path) {
return Some((repo, path.clone()));
}
let mut pathage = match resolve_filepath(path) {
Ok(p) => p,
_ => return None,
};
if pathage.parent().is_none() {
pathage = PathBuf::from(format!("./{}", path))
}
while pathage.pop() {
if let Ok(repo) = Repository::open(pathage.as_os_str()) {
return Some((
repo,
pathage.into_os_string().into_string().expect("git repo root of string that we know matches a filesystem entry is not a valid string"),
));
}
}
return None;
}
fn main() -> Result<()> {
let args = Cli::parse();
let size = terminal_size();
let mut terminal_width: usize = 80;
if let Some((Width(w), Height(_))) = size {
terminal_width = w as usize;
} else {
eprintln!(
"Unable to get terminal size, defaulting width to {} characters",
terminal_width
);
}
match args.command {
Commands::View { file } => {
let file_check = metadata(&file);
match file_check {
Ok(met) => {
let (repo, repo_path) = match find_git_root(&file) {
Some((x, y)) => (x, y),
None => {
bail!("{} is not within a git repo", file);
}
};
let mut file_path = resolve_filepath(&file)
.unwrap()
.into_os_string()
.into_string()
.unwrap()
.replace(&repo_path, "");
file_path = file_path.trim_start_matches('/').to_string();
if met.is_dir() {
let mut paths = read_dir(&file)?
.map(|x| {
x.expect("asdf")
.path()
.into_os_string()
.into_string()
.unwrap()
})
.collect::<Vec<String>>();
paths.sort_by(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()));
for path in paths {
let mut path_from_git_root =
resolve_relative_filepath(&path, &repo_path)?;
path_from_git_root =
path_from_git_root.trim_start_matches('/').to_string();
let blame = repo.blame_file(Path::new(&path_from_git_root), None);
match blame {
Ok(blame) => {
let latest_hunk = blame
.iter()
.reduce(|acc: BlameHunk, curr| {
curr.final_commit_id();
let curr_when = curr.final_signature().when();
if acc.final_signature().when() > curr_when {
acc
} else {
curr
}
})
.unwrap();
let signature = latest_hunk.final_signature();
let most_recent_commit =
repo.find_commit(latest_hunk.final_commit_id())?;
let name = signature
.name()
.unwrap_or(signature.email().unwrap_or("n/@"));
let summary = most_recent_commit.summary().unwrap();
d!(
"{:.40} | {} | {:.7} | {:<7.7} | {:#?}",
path_from_git_root,
summary,
latest_hunk.final_commit_id(),
name,
display_time(latest_hunk.final_signature().when()),
);
}
Err(err) => match err.code() {
ErrorCode::NotFound => continue,
code => {
d!(
"{}:{} could not get info {:#?}",
path_from_git_root,
path,
code
)
}
},
};
}
return Ok(());
} else if met.is_file() {
let blame = repo
.blame_file(Path::new(&file_path), None)
.expect("unable to read blame for file");
let commit_id = "HEAD".to_string();
let spec = format!("{}:{}", commit_id, file_path);
let object = repo.revparse_single(&spec[..]).unwrap();
let blob = repo.find_blob(object.id()).unwrap();
let reader = BufReader::new(blob.content());
for (i, line) in reader.lines().enumerate() {
if let (Ok(line), Some(hunk)) = (line, blame.get_line(i + 1)) {
let signature = hunk.final_signature();
let name = signature
.name()
.unwrap_or(signature.email().unwrap_or("n/@"));
println!(
"{:.7} {:<7.7}|{}",
hunk.final_commit_id(),
name,
line.substring(0, terminal_width - BLAME_ID_WIDTH),
);
}
}
return Ok(());
}
return Ok(());
}
_ => {
eprintln!("could not find file {}", file);
return Ok(());
}
}
}
}
}