// Copyright (c) 2020 Teddy Wing
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::Path;
use git2::{Patch, Repository};
use regex::Regex;
use serde::Deserialize;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error(transparent)]
Git(#[from] git2::Error),
#[error("{0} is not a blob")]
GitObjectNotBlob(git2::Oid),
#[error("{message}")]
BufWriter {
source: std::io::IntoInnerError>>,
message: String,
},
#[error("{message}: {source}")]
Io {
source: std::io::Error,
message: String,
},
#[error("{0} is not valid UTF-8")]
InvalidUtf8(String),
#[error("Regex error: {0}")]
Regex(#[from] regex::Error),
}
#[derive(Debug, PartialEq)]
enum LineEnding {
Lf,
CrLf,
}
/// A suggestion comment extracted from the GitHub API.
#[derive(Debug, Deserialize)]
pub struct Suggestion {
#[serde(rename = "diff_hunk")]
diff: String,
#[serde(rename = "body")]
comment: String,
#[serde(rename = "original_commit_id")]
commit: String,
path: String,
original_start_line: Option,
#[serde(rename = "original_line")]
original_end_line: usize,
}
impl Suggestion {
/// Get the suggestion diff for the current repository.
pub fn diff(&self) -> Result {
let repo = Repository::open(".")?;
self.diff_with_repo(&repo)
}
/// Get the suggestion diff for `repo`.
fn diff_with_repo(&self, repo: &Repository) -> Result {
let commit = repo.find_commit(self.commit.parse()?)?;
let path = Path::new(&self.path);
let object = commit
.tree()?
.get_path(path)?
.to_object(&repo)?;
let blob = object.as_blob()
.ok_or_else(|| Error::GitObjectNotBlob(object.id()))?;
let blob_reader = BufReader::new(blob.content());
let mut new = BufWriter::new(Vec::new());
self.apply_to(blob_reader, &mut new)?;
let new_buffer = new.into_inner()
.map_err(|e| Error::BufWriter {
source: e,
message: "unable to read right side of patch".to_owned(),
})?;
let mut diff = Patch::from_blob_and_buffer(
blob,
Some(&path),
&new_buffer,
Some(&path),
None,
)?;
Ok(
diff.to_buf()?
.as_str()
.ok_or_else(|| Error::InvalidUtf8("diff".to_owned()))?
.to_owned()
)
}
/// Extract suggestion code from a comment body.
fn suggestion_with_line_ending(
&self,
line_ending: &LineEnding,
) -> Result {
let re = Regex::new(r"(?s).*(?-s)```\s*suggestion.*\n")?;
let s = re.replace(&self.comment, "");
let s = s.replace("```", "");
// Suggestion blocks use CRLF by default.
if *line_ending == LineEnding::Lf {
return Ok(s.replace('\r', ""));
}
Ok(s)
}
/// Apply the suggestion to the current repository.
pub fn apply(&self) -> Result<(), Error> {
let repo = Repository::open(".")?;
let diff_text = self.diff_with_repo(&repo)?;
let diff = git2::Diff::from_buffer(diff_text.as_bytes())?;
repo.apply(
&diff,
git2::ApplyLocation::WorkDir,
None,
)?;
Ok(())
}
/// Apply the patch in `reader` to `writer`.
fn apply_to(
&self,
reader: R,
writer: &mut W,
) -> Result<(), Error> {
let mut line_ending = LineEnding::Lf;
for (i, line) in reader.lines().enumerate() {
let line_number = i + 1;
let line = line.map_err(|e| Error::Io {
source: e,
message: "Unable to read line".to_owned(),
})?;
// Determine which line endings the file uses by looking at the
// first line.
if line_number == 1 && is_line_crlf(&line) {
line_ending = LineEnding::CrLf;
}
if line_number == self.original_end_line {
write!(
writer,
"{}",
self.suggestion_with_line_ending(&line_ending)?,
).map_err(|e| Error::Io {
source: e,
message: "Write error".to_owned(),
})?;
} else if self.original_start_line.is_none()
|| line_number < self.original_start_line.unwrap()
|| line_number > self.original_end_line {
writeln!(writer, "{}", line)
.map_err(|e| Error::Io {
source: e,
message: "Write error".to_owned(),
})?;
}
}
Ok(())
}
}
/// Determine the line ending for `line`.
///
/// If the second-to-last character on the first line is "\r", assume CRLF.
/// Otherwise, default to LF.
fn is_line_crlf(line: &str) -> bool {
if let Some(c) = line.chars().rev().nth(2) {
if c == '\r' {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn suggestion_diff_with_repo_generates_diff() {
use tempfile::tempdir;
let git_root = tempdir().unwrap();
let repo = Repository::init(git_root.path()).unwrap();
let file = r#"
‘Beware the Jabberwock, my son!
The jaws that bite, the claws that catch!
Beware the Jubjub bird, and shun
The frumious Bandersnatch!’
He took his vorpal blade in hand:
Long time the manxome foe he sought--
So rested he by the Tumtum tree,
And stood awhile in thought.
"#;
let path = "poems/Jabberwocky.txt";
let mut index = repo.index().unwrap();
index.add_frombuffer(
&git2::IndexEntry {
ctime: git2::IndexTime::new(0, 0),
mtime: git2::IndexTime::new(0, 0),
dev: 0,
ino: 0,
mode: 0o100644,
uid: 0,
gid: 0,
file_size: file.len() as u32,
id: git2::Oid::zero(),
flags: 0,
flags_extended: 0,
path: path.as_bytes().to_vec(),
},
file.as_bytes(),
).unwrap();
let tree_oid = index.write_tree().unwrap();
let tree = repo.find_tree(tree_oid).unwrap();
let author = git2::Signature::now(
"Oshino Shinobu",
"oshino.shinobu@example.com",
).unwrap();
let commit = repo.commit(
Some("HEAD"),
&author,
&author,
"Sample commit",
&tree,
&[],
).unwrap();
let suggestion = Suggestion {
diff: "".to_owned(),
comment: r#"``` suggestion
He took his vorpal sword in hand:
Long time the manxome foe he sought—
```"#.to_owned(),
commit: commit.to_string(),
path: path.to_owned(),
original_start_line: Some(7),
original_end_line: 8,
};
let expected = r#"diff --git a/poems/Jabberwocky.txt b/poems/Jabberwocky.txt
index 89840a2..06acdfc 100644
--- a/poems/Jabberwocky.txt
+++ b/poems/Jabberwocky.txt
@@ -4,7 +4,7 @@
Beware the Jubjub bird, and shun
The frumious Bandersnatch!’
- He took his vorpal blade in hand:
- Long time the manxome foe he sought--
+ He took his vorpal sword in hand:
+ Long time the manxome foe he sought—
So rested he by the Tumtum tree,
And stood awhile in thought.
"#;
assert_eq!(
suggestion.diff_with_repo(&repo).unwrap(),
expected,
);
}
#[test]
fn suggestion_apply_to_writes_patch_to_writer() {
use std::io::Cursor;
let mut original_buffer = Vec::new();
let original = r#"
‘Beware the Jabberwock, my son!
The jaws that bite, the claws that catch!
Beware the Jubjub bird, and shun
The frumious Bandersnatch!’
He took his vorpal blade in hand:
Long time the manxome foe he sought--
So rested he by the Tumtum tree,
And stood awhile in thought.
"#;
write!(original_buffer, "{}", original).unwrap();
let suggestion = Suggestion {
diff: "".to_owned(),
comment: r#"``` suggestion
He took his vorpal sword in hand:
Long time the manxome foe he sought—
```"#.to_owned(),
commit: "".to_owned(),
path: "".to_owned(),
original_start_line: Some(7),
original_end_line: 8,
};
let expected = r#"
‘Beware the Jabberwock, my son!
The jaws that bite, the claws that catch!
Beware the Jubjub bird, and shun
The frumious Bandersnatch!’
He took his vorpal sword in hand:
Long time the manxome foe he sought—
So rested he by the Tumtum tree,
And stood awhile in thought.
"#;
let original_reader = Cursor::new(original_buffer);
let mut actual = Cursor::new(Vec::new());
suggestion.apply_to(original_reader, &mut actual).unwrap();
assert_eq!(
std::str::from_utf8(&actual.into_inner()).unwrap(),
expected,
);
}
}