git-whence 0.2.1

interactive git reblame
use std::{
	collections::HashMap,
	error,
	path::Path,
	rc::Rc,
	time::{self, SystemTime},
};

use nom::{
	bytes::complete::{tag, take_till, take_till1, take_until1, take_while1},
	character::complete::line_ending,
	combinator::{opt, peek},
	sequence::{delimited, preceded, terminated, Tuple},
	AsChar, IResult,
};

#[derive(Debug, PartialEq, Eq)]
pub struct BlameLine<'a> {
	pub commit: &'a str,
	pub line_num: i32,
	pub code: Vec<&'a str>,
	pub info: Rc<CommitInfo<'a>>,
}

#[derive(Debug, PartialEq, Eq)]
pub struct CommitInfo<'a> {
	pub author: &'a str,
	pub commit_time: SystemTime,
	pub path: Option<&'a Path>,
}

pub fn parse_blame_porcelain(input: &str) -> Result<Vec<BlameLine<'_>>, Box<dyn error::Error>> {
	let tab = tag::<&str, &str, ()>("\t");
	let mut parse_code = delimited(tab, take_till(is_line_ending), line_ending);

	let mut hunks = vec![];
	let mut commits: HashMap<&str, Rc<CommitInfo>> = HashMap::new();
	let mut remaining = input;
	while !remaining.is_empty() {
		let header;
		(remaining, header) = match parse_header(remaining) {
			Ok(r) => r,
			Err(e) => return Err(Box::new(e.map_input(|es| es.to_owned()))),
		};

		let commit_info = commits.entry(header.commit).or_insert_with(|| {
			let commit_info;
			(remaining, commit_info) = parse_commit_info(remaining).expect("expected commit info");
			Rc::new(commit_info)
		});

		let code_line;
		(remaining, code_line) = parse_code(remaining)?;
		let mut code = vec![code_line];

		for _ in 1..header.group_size {
			(remaining, _) = match parse_header(remaining) {
				Ok(r) => r,
				Err(e) => return Err(Box::new(e.map_input(|es| es.to_owned()))),
			};
			let code_line;
			(remaining, code_line) = parse_code(remaining)?;
			code.push(code_line);
		}

		hunks.push(BlameLine {
			commit: header.commit,
			line_num: header.line_no,
			code,
			info: commit_info.to_owned(),
		});
	}
	Ok(hunks)
}

#[derive(Debug, PartialEq, Eq)]
struct Header<'a> {
	commit: &'a str,
	line_no: i32,
	group_size: i32,
}

fn parse_header(input: &str) -> IResult<&str, Header> {
	let space = tag(" ");
	let commit = terminated(take_until1(" "), &space);
	let orig_line = terminated(take_until1(" "), &space);
	let final_line = take_while1(is_digit);
	let group_size = opt(preceded(&space, take_while1(is_digit)));
	let (remaining, (commit, _, final_line, group_size, _)) =
		(commit, orig_line, final_line, group_size, line_ending).parse(input)?;
	Ok((
		remaining,
		Header {
			commit,
			line_no: final_line.parse().unwrap(),
			group_size: match group_size {
				Some(b) => b.parse().unwrap(),
				None => 1,
			},
		},
	))
}

fn parse_commit_info(input: &str) -> IResult<&str, CommitInfo> {
	let mut parse_line = (
		terminated(take_until1(" "), tag(" ")),
		terminated(take_till1(is_line_ending), line_ending),
	);

	let mut ret = CommitInfo {
		author: "",
		commit_time: SystemTime::UNIX_EPOCH,
		path: None,
	};
	let mut remaining = input;
	while !remaining.is_empty() {
		match peek(tag::<&str, &str, ()>("\t"))(remaining) {
			Ok(_) => {
				return Ok((remaining, ret));
			}
			Err(_) => {
				let (field, value);
				(remaining, (field, value)) = parse_line.parse(remaining)?;
				match field {
					"author" => ret.author = value,
					"committer-time" => {
						let timestamp: u64 = value.parse().unwrap();
						ret.commit_time = make_time(timestamp);
					}
					"filename" => ret.path = Some(Path::new(value)),
					_ => {}
				}
			}
		};
	}
	panic!("couldn't find code line starting with tab")
}

#[inline]
fn is_digit(c: char) -> bool {
	c.is_dec_digit()
}

#[inline]
fn is_line_ending(c: char) -> bool {
	c == '\n' || c == '\r'
}

fn make_time(ts: u64) -> SystemTime {
	SystemTime::UNIX_EPOCH + time::Duration::from_secs(ts)
}

#[cfg(test)]
mod tests {
	use std::{
		path::Path,
		rc::Rc,
		time::{self, SystemTime},
	};

	use crate::git_blame_porcelain::{make_time, parse_blame_porcelain, parse_header, BlameLine, CommitInfo, Header};

	use super::parse_commit_info;

	const TEST_BLAME_OUTPUT: &str = include_str!("../fixtures/test_blame_output");

	#[test]
	fn parse() {
		let result = parse_blame_porcelain(TEST_BLAME_OUTPUT).expect("couldn't parse blame");
		let expected = vec![
			BlameLine {
				commit: "116aa62bf54a39697e25f21d6cf6799f7faa1349",
				line_num: 1,
				code: vec![
					":mod:`gc` --- Garbage Collector interface",
					"=========================================",
					"",
					".. module:: gc",
					"   :synopsis: Interface to the cycle-detecting garbage collector.",
				],
				info: Rc::new(CommitInfo {
					author: "Georg Brandl",
					commit_time: make_time(1187188102),
					path: Some(Path::new("Doc/library/gc.rst")),
				}),
			},
			BlameLine {
				commit: "fa089b9b0b926c04e5d57812b7d7653472787965",
				line_num: 6,
				code: vec![""],
				info: Rc::new(CommitInfo {
					author: "Terry Jan Reedy",
					commit_time: make_time(1465671774),
					path: Some(Path::new("Doc/library/gc.rst")),
				}),
			},
			BlameLine {
				commit: "116aa62bf54a39697e25f21d6cf6799f7faa1349",
				line_num: 7,
				code: vec![
					".. moduleauthor:: Neil Schemenauer <nas@arctrix.com>",
					".. sectionauthor:: Neil Schemenauer <nas@arctrix.com>",
					"",
				],
				info: Rc::new(CommitInfo {
					author: "Georg Brandl",
					commit_time: make_time(1187188102),
					path: Some(Path::new("Doc/library/gc.rst")),
				}),
			},
			BlameLine {
				commit: "fa089b9b0b926c04e5d57812b7d7653472787965",
				line_num: 10,
				code: vec!["--------------"],
				info: Rc::new(CommitInfo {
					author: "Terry Jan Reedy",
					commit_time: make_time(1465671774),
					path: Some(Path::new("Doc/library/gc.rst")),
				}),
			},
		];
		for (actual_line, expected_line) in std::iter::zip(&result, &expected) {
			assert_eq!(actual_line, expected_line);
		}
		assert_eq!(result.len(), expected.len());
	}

	#[test]
	fn header() {
		let result = parse_header(TEST_BLAME_OUTPUT).expect("couldn't parse blame");
		assert_eq!(
			result.1,
			Header {
				commit: "116aa62bf54a39697e25f21d6cf6799f7faa1349",
				line_no: 1,
				group_size: 5,
			}
		);
	}

	#[test]
	fn commit_info() {
		let data = "author raylu
author-mail <mail@fake.tld>
author-time 1234567890
author-tz -1100
committer someguy
committer-mail <fake@mail.tld>
committer-time 9876543210
committer-tz +1100
summary blah blah
previous c92bf83a829956e683a3d6bb1ae65aed74d7b92a Doc/library/gc.rst
filename Doc/library/gc.rst
	line of code
";
		let (remaining, commit_info) = parse_commit_info(data).expect("couldn't parse commit info");
		assert_eq!(remaining, "\tline of code\n");
		assert_eq!(
			commit_info,
			CommitInfo {
				author: "raylu",
				commit_time: SystemTime::UNIX_EPOCH + time::Duration::from_secs(9876543210),
				path: Some(Path::new("Doc/library/gc.rst")),
			}
		);
	}
}