use std::collections::VecDeque;
use std::fmt::Write as _;
use std::ops::{Deref, Not, Range};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::{fmt, io};
use radicle::cob;
use radicle::cob::patch::{PatchId, Revision, Verdict};
use radicle::cob::{CodeLocation, CodeRange};
use radicle::crypto;
use radicle::git;
use radicle::git::Oid;
use radicle::node::device::Device;
use radicle::prelude::*;
use radicle::storage::git::{cob::DraftStore, Repository};
use radicle_surf::diff::*;
use radicle_term::{Element, VStack};
use crate::git::pretty_diff::ToPretty;
use crate::git::pretty_diff::{Blob, Blobs, Repo};
use crate::git::unified_diff::{self, FileHeader};
use crate::git::unified_diff::{Encode, HunkHeader};
use crate::terminal as term;
use crate::terminal::highlight::Highlighter;
const HELP: &str = "\
y - accept this hunk
n - ignore this hunk
c - comment on this hunk
j - leave this hunk undecided, see next hunk
k - leave this hunk undecided, see previous hunk
s - split the current hunk into smaller hunks
q - quit; do not accept this hunk nor any of the remaining ones
? - print help";
trait PromptWriter: io::Write {
fn is_terminal(&self) -> bool;
}
impl PromptWriter for Box<dyn PromptWriter> {
fn is_terminal(&self) -> bool {
self.deref().is_terminal()
}
}
impl<T: io::Write + io::IsTerminal> PromptWriter for T {
fn is_terminal(&self) -> bool {
<Self as io::IsTerminal>::is_terminal(self)
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum ReviewAction {
Accept,
Ignore,
Comment,
Split,
Next,
Previous,
Help,
Quit,
}
impl ReviewAction {
fn prompt(
mut input: impl io::BufRead,
mut output: impl io::Write,
prompt: impl fmt::Display,
) -> io::Result<Option<Self>> {
write!(&mut output, "{prompt} ")?;
let mut s = String::new();
input.read_line(&mut s)?;
if s.trim().is_empty() {
return Ok(None);
}
Self::from_str(s.trim())
.map(Some)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
}
}
impl std::fmt::Display for ReviewAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Accept => write!(f, "y"),
Self::Ignore => write!(f, "n"),
Self::Comment => write!(f, "c"),
Self::Split => write!(f, "s"),
Self::Next => write!(f, "j"),
Self::Previous => write!(f, "k"),
Self::Help => write!(f, "?"),
Self::Quit => write!(f, "q"),
}
}
}
impl FromStr for ReviewAction {
type Err = io::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"y" => Ok(Self::Accept),
"n" => Ok(Self::Ignore),
"c" => Ok(Self::Comment),
"s" => Ok(Self::Split),
"j" => Ok(Self::Next),
"k" => Ok(Self::Previous),
"?" => Ok(Self::Help),
"q" => Ok(Self::Quit),
_ => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid action '{s}'"),
)),
}
}
}
#[derive(Debug)]
pub enum ReviewItem {
FileAdded {
path: PathBuf,
header: FileHeader,
new: DiffFile,
hunk: Option<Hunk<Modification>>,
},
FileDeleted {
path: PathBuf,
header: FileHeader,
old: DiffFile,
hunk: Option<Hunk<Modification>>,
},
FileModified {
path: PathBuf,
header: FileHeader,
old: DiffFile,
new: DiffFile,
hunk: Option<Hunk<Modification>>,
},
FileMoved {
moved: Moved,
},
FileCopied {
copied: Copied,
},
FileEofChanged {
path: PathBuf,
header: FileHeader,
old: DiffFile,
new: DiffFile,
eof: EofNewLine,
},
FileModeChanged {
path: PathBuf,
header: FileHeader,
old: DiffFile,
new: DiffFile,
},
}
impl ReviewItem {
fn hunk(&self) -> Option<&Hunk<Modification>> {
match self {
Self::FileAdded { hunk, .. } => hunk.as_ref(),
Self::FileDeleted { hunk, .. } => hunk.as_ref(),
Self::FileModified { hunk, .. } => hunk.as_ref(),
Self::FileMoved { .. }
| Self::FileCopied { .. }
| Self::FileEofChanged { .. }
| Self::FileModeChanged { .. } => None,
}
}
fn hunk_header(&self) -> Option<HunkHeader> {
self.hunk().and_then(|h| HunkHeader::try_from(h).ok())
}
fn paths(&self) -> (Option<(&Path, Oid)>, Option<(&Path, Oid)>) {
match self {
Self::FileAdded { path, new, .. } => (None, Some((path, Oid::from(*new.oid)))),
Self::FileDeleted { path, old, .. } => (Some((path, Oid::from(*old.oid))), None),
Self::FileMoved { moved } => (
Some((&moved.old_path, Oid::from(*moved.old.oid))),
Some((&moved.new_path, Oid::from(*moved.new.oid))),
),
Self::FileCopied { copied } => (
Some((&copied.old_path, Oid::from(*copied.old.oid))),
Some((&copied.new_path, Oid::from(*copied.new.oid))),
),
Self::FileModified { path, old, new, .. } => (
Some((path, Oid::from(*old.oid))),
Some((path, Oid::from(*new.oid))),
),
Self::FileEofChanged { path, old, new, .. } => (
Some((path, Oid::from(*old.oid))),
Some((path, Oid::from(*new.oid))),
),
Self::FileModeChanged { path, old, new, .. } => (
Some((path, Oid::from(*old.oid))),
Some((path, Oid::from(*new.oid))),
),
}
}
fn file_header(&self) -> FileHeader {
match self {
Self::FileAdded { header, .. } => header.clone(),
Self::FileDeleted { header, .. } => header.clone(),
Self::FileMoved { moved } => FileHeader::Moved {
old_path: moved.old_path.clone(),
new_path: moved.new_path.clone(),
},
Self::FileCopied { copied } => FileHeader::Copied {
old_path: copied.old_path.clone(),
new_path: copied.new_path.clone(),
},
Self::FileModified { header, .. } => header.clone(),
Self::FileEofChanged { header, .. } => header.clone(),
Self::FileModeChanged { header, .. } => header.clone(),
}
}
fn blobs<R: Repo>(&self, repo: &R) -> Blobs<(PathBuf, Blob)> {
let (old, new) = self.paths();
Blobs::from_paths(old, new, repo)
}
fn pretty<R: Repo>(&self, repo: &R) -> Box<dyn Element> {
let mut hi = Highlighter::default();
let blobs = self.blobs(repo);
let highlighted = blobs.highlight(&mut hi);
let header = self.file_header();
match self {
Self::FileMoved { moved } => moved.pretty(&mut hi, &header, repo),
Self::FileCopied { copied } => copied.pretty(&mut hi, &header, repo),
Self::FileModified { hunk, .. }
| Self::FileAdded { hunk, .. }
| Self::FileDeleted { hunk, .. } => {
let header = header.pretty(&mut hi, &None, repo);
let vstack = term::VStack::default()
.border(Some(term::colors::FAINT))
.padding(1)
.child(header);
if let Some(hunk) = hunk {
let hunk = hunk.pretty(&mut hi, &highlighted, repo);
if !hunk.is_empty() {
return vstack.divider().merge(hunk).boxed();
}
}
vstack
}
Self::FileEofChanged { eof, .. } => match eof {
EofNewLine::NewMissing => {
VStack::default().child(term::Label::new("`\\n` missing at end-of-file"))
}
EofNewLine::OldMissing => {
VStack::default().child(term::Label::new("`\\n` added at end-of-file"))
}
EofNewLine::BothMissing | EofNewLine::NoneMissing => VStack::default(),
},
Self::FileModeChanged { .. } => VStack::default(),
}
.boxed()
}
}
#[derive(Default)]
pub struct ReviewQueue {
queue: VecDeque<(usize, ReviewItem)>,
}
impl ReviewQueue {
fn add_file(&mut self, file: FileDiff) {
let header = FileHeader::from(&file);
match file {
FileDiff::Moved(moved) => {
self.add_item(ReviewItem::FileMoved { moved });
}
FileDiff::Copied(copied) => {
self.add_item(ReviewItem::FileCopied { copied });
}
FileDiff::Added(a) => {
self.add_item(ReviewItem::FileAdded {
path: a.path,
header: header.clone(),
new: a.new,
hunk: if let DiffContent::Plain {
hunks: Hunks(mut hs),
..
} = a.diff
{
hs.pop()
} else {
None
},
});
}
FileDiff::Deleted(d) => {
self.add_item(ReviewItem::FileDeleted {
path: d.path,
header: header.clone(),
old: d.old,
hunk: if let DiffContent::Plain {
hunks: Hunks(mut hs),
..
} = d.diff
{
hs.pop()
} else {
None
},
});
}
FileDiff::Modified(m) => {
if m.old.mode != m.new.mode {
self.add_item(ReviewItem::FileModeChanged {
path: m.path.clone(),
header: header.clone(),
old: m.old.clone(),
new: m.new.clone(),
});
}
match m.diff {
DiffContent::Empty => {
}
DiffContent::Binary => {
self.add_item(ReviewItem::FileModified {
path: m.path.clone(),
header: header.clone(),
old: m.old.clone(),
new: m.new.clone(),
hunk: None,
});
}
DiffContent::Plain {
hunks: Hunks(hunks),
eof,
..
} => {
for hunk in hunks {
self.add_item(ReviewItem::FileModified {
path: m.path.clone(),
header: header.clone(),
old: m.old.clone(),
new: m.new.clone(),
hunk: Some(hunk),
});
}
if let EofNewLine::OldMissing | EofNewLine::NewMissing = eof {
self.add_item(ReviewItem::FileEofChanged {
path: m.path.clone(),
header: header.clone(),
old: m.old.clone(),
new: m.new.clone(),
eof,
})
}
}
}
}
}
}
fn add_item(&mut self, item: ReviewItem) {
self.queue.push_back((self.queue.len(), item));
}
}
impl From<Diff> for ReviewQueue {
fn from(diff: Diff) -> Self {
let mut queue = Self::default();
for file in diff.into_files() {
queue.add_file(file);
}
queue
}
}
impl std::ops::Deref for ReviewQueue {
type Target = VecDeque<(usize, ReviewItem)>;
fn deref(&self) -> &Self::Target {
&self.queue
}
}
impl std::ops::DerefMut for ReviewQueue {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.queue
}
}
impl Iterator for ReviewQueue {
type Item = (usize, ReviewItem);
fn next(&mut self) -> Option<Self::Item> {
self.queue.pop_front()
}
}
pub struct FileReviewBuilder {
header: FileHeader,
delta: i32,
}
impl FileReviewBuilder {
fn new(item: &ReviewItem) -> Self {
Self {
header: item.file_header(),
delta: 0,
}
}
fn set_item(&mut self, item: &ReviewItem) -> &mut Self {
let header = item.file_header();
if self.header != header {
self.header = header;
self.delta = 0;
}
self
}
fn ignore_item(&mut self, item: &ReviewItem) {
if let Some(h) = item.hunk_header() {
self.delta += h.new_size as i32 - h.old_size as i32;
}
}
fn item_diff(&mut self, item: ReviewItem) -> Result<git::raw::Diff<'_>, Error> {
let mut buf = Vec::new();
let mut writer = unified_diff::Writer::new(&mut buf);
writer.encode(&self.header)?;
if let (Some(h), Some(mut header)) = (item.hunk(), item.hunk_header()) {
header.old_line_no -= self.delta as u32;
header.new_line_no -= self.delta as u32;
let h = Hunk {
header: header.to_unified_string()?.as_bytes().to_owned().into(),
lines: h.lines.clone(),
old: h.old.clone(),
new: h.new.clone(),
};
writer.encode(&h)?;
}
drop(writer);
git::raw::Diff::from_buffer(&buf).map_err(Error::from)
}
}
pub struct Brain<'a> {
refname: git::fmt::Namespaced<'a>,
head: git::raw::Commit<'a>,
accepted: git::raw::Tree<'a>,
}
impl<'a> Brain<'a> {
fn new(
patch: PatchId,
remote: &NodeId,
base: git::raw::Commit,
repo: &'a git::raw::Repository,
) -> Result<Self, git::raw::Error> {
let refname = Self::refname(&patch, remote);
let author = repo.signature()?;
let oid = repo.commit(
Some(refname.as_str()),
&author,
&author,
&format!("Review for {patch}"),
&base.tree()?,
&[&base],
)?;
let head = repo.find_commit(oid)?;
let tree = head.tree()?;
Ok(Self {
refname,
head,
accepted: tree,
})
}
fn cid(&self) -> Oid {
self.accepted.id().into()
}
fn load(
patch: PatchId,
remote: &NodeId,
repo: &'a git::raw::Repository,
) -> Result<Self, git::raw::Error> {
let refname = Self::refname(&patch, remote);
let head = repo.find_reference(&refname)?.peel_to_commit()?;
let tree = head.tree()?;
Ok(Self {
refname,
head,
accepted: tree,
})
}
fn accept(
&mut self,
diff: git::raw::Diff,
repo: &'a git::raw::Repository,
) -> Result<(), git::raw::Error> {
let mut index = repo.apply_to_tree(&self.accepted, &diff, None)?;
let accepted = index.write_tree_to(repo)?;
self.accepted = repo.find_tree(accepted)?;
let head = self.head.amend(
Some(&self.refname),
None,
None,
None,
None,
Some(&self.accepted),
)?;
self.head = repo.find_commit(head)?;
Ok(())
}
fn refname(patch: &PatchId, remote: &NodeId) -> git::fmt::Namespaced<'a> {
git::refs::storage::draft::review(remote, patch)
}
}
pub struct ReviewBuilder<'a> {
patch_id: PatchId,
repo: &'a Repository,
hunk: Option<usize>,
verdict: Option<Verdict>,
}
impl<'a> ReviewBuilder<'a> {
pub fn new(patch_id: PatchId, repo: &'a Repository) -> Self {
Self {
patch_id,
repo,
hunk: None,
verdict: None,
}
}
pub fn hunk(mut self, hunk: Option<usize>) -> Self {
self.hunk = hunk;
self
}
pub fn verdict(mut self, verdict: Option<Verdict>) -> Self {
self.verdict = verdict;
self
}
pub fn run<G>(
self,
revision: &Revision,
opts: &mut git::raw::DiffOptions,
signer: &Device<G>,
) -> anyhow::Result<()>
where
G: crypto::signature::Signer<crypto::Signature>,
{
let repo = self.repo.raw();
let base = repo.find_commit((*revision.base()).into())?;
let patch_id = self.patch_id;
let tree = {
let commit = repo.find_commit(revision.head().into())?;
commit.tree()?
};
let stdout = io::stdout().lock();
let mut stdin = io::stdin().lock();
let mut writer: Box<dyn PromptWriter> = if self.hunk.is_some() || !stdout.is_terminal() {
Box::new(stdout)
} else {
Box::new(io::stderr().lock())
};
let mut brain = if let Ok(b) = Brain::load(self.patch_id, signer.public_key(), repo) {
term::success!(
"Loaded existing review {} for patch {}",
term::format::secondary(term::format::parens(term::format::oid(b.head.id()))),
term::format::tertiary(&patch_id)
);
b
} else {
Brain::new(self.patch_id, signer.public_key(), base, repo)?
};
let diff = self.diff(&brain.accepted, &tree, repo, opts)?;
let drafts = DraftStore::new(self.repo, *signer.public_key());
let mut patches = cob::patch::Cache::no_cache(&drafts)?;
let mut patch = patches.get_mut(&patch_id)?;
let mut queue = ReviewQueue::from(diff);
if queue.is_empty() {
term::success!("All hunks have been reviewed");
return Ok(());
}
let review = if let Some(r) = revision.review_by(signer.public_key()) {
r.id()
} else {
patch.review(
revision.id(),
Some(Verdict::Reject),
None,
vec![],
signer,
)?
};
let mut file: Option<FileReviewBuilder> = None;
let total = queue.len();
while let Some((ix, item)) = queue.next() {
if let Some(hunk) = self.hunk {
if hunk != ix + 1 {
continue;
}
}
let progress = term::format::secondary(format!("({}/{total})", ix + 1));
let file = match file.as_mut() {
Some(fr) => fr.set_item(&item),
None => file.insert(FileReviewBuilder::new(&item)),
};
term::element::write_to(
&item.pretty(repo),
&mut writer,
term::Constraint::from_env().unwrap_or_default(),
)?;
match self.prompt(&mut stdin, &mut writer, progress) {
Some(ReviewAction::Accept) => {
let diff = file.item_diff(item)?;
brain.accept(diff, repo)?;
if self.hunk.is_some() {
term::success!("Updated brain to {}", brain.cid());
}
}
Some(ReviewAction::Ignore) => {
file.ignore_item(&item);
}
Some(ReviewAction::Comment) => {
let (old, new) = item.paths();
let path = old.or(new);
if let (Some(hunk), Some((path, _))) = (item.hunk(), path) {
let builder = CommentBuilder::new(revision.head(), path.to_path_buf());
let comments = builder.edit(hunk)?;
patch.transaction("Review comments", signer, |tx| {
for comment in comments {
tx.review_comment(
review,
comment.body,
Some(comment.location),
None, vec![], )?;
}
Ok(())
})?;
} else {
eprintln!(
"{}",
term::format::tertiary(
"Commenting on binary blobs is not yet implemented"
)
.bold()
);
queue.push_front((ix, item));
}
}
Some(ReviewAction::Split) => {
eprintln!(
"{}",
term::format::tertiary("Splitting is not yet implemented").bold()
);
queue.push_front((ix, item));
}
Some(ReviewAction::Next) => {
queue.push_back((ix, item));
}
Some(ReviewAction::Previous) => {
queue.push_front((ix, item));
if let Some(e) = queue.pop_back() {
queue.push_front(e);
}
}
Some(ReviewAction::Quit) => {
break;
}
Some(ReviewAction::Help) => {
eprintln!("{}", term::format::tertiary(HELP).bold());
queue.push_front((ix, item));
}
None => {
eprintln!(
"{}",
term::format::secondary(format!(
"{} hunk(s) remaining to review",
queue.len() + 1
))
);
queue.push_front((ix, item));
}
}
}
Ok(())
}
fn diff(
&self,
brain: &git::raw::Tree<'_>,
tree: &git::raw::Tree<'_>,
repo: &'a git::raw::Repository,
opts: &mut git::raw::DiffOptions,
) -> Result<Diff, Error> {
let mut find_opts = git::raw::DiffFindOptions::new();
find_opts.exact_match_only(true);
find_opts.all(true);
find_opts.copies(false);
let mut diff = repo.diff_tree_to_tree(Some(brain), Some(tree), Some(opts))?;
diff.find_similar(Some(&mut find_opts))?;
let diff = Diff::try_from(diff)?;
Ok(diff)
}
fn prompt(
&self,
mut input: impl io::BufRead,
output: &mut impl PromptWriter,
progress: impl fmt::Display,
) -> Option<ReviewAction> {
if let Some(v) = self.verdict {
match v {
Verdict::Accept => Some(ReviewAction::Accept),
Verdict::Reject => Some(ReviewAction::Ignore),
}
} else if output.is_terminal() {
let prompt = term::format::secondary("Accept this hunk? [y,n,c,j,k,q,?]").bold();
ReviewAction::prompt(&mut input, output, format!("{progress} {prompt}"))
.unwrap_or(Some(ReviewAction::Help))
} else {
Some(ReviewAction::Ignore)
}
}
}
#[derive(Debug, PartialEq, Eq)]
struct ReviewComment {
location: CodeLocation,
body: String,
}
#[derive(thiserror::Error, Debug)]
enum Error {
#[error(transparent)]
Diff(#[from] unified_diff::Error),
#[error(transparent)]
Surf(#[from] radicle_surf::diff::git::error::Diff),
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Format(#[from] std::fmt::Error),
#[error(transparent)]
Git(#[from] git::raw::Error),
}
#[derive(Debug)]
struct CommentBuilder {
commit: Oid,
path: PathBuf,
comments: Vec<ReviewComment>,
}
impl CommentBuilder {
fn new(commit: Oid, path: PathBuf) -> Self {
Self {
commit,
path,
comments: Vec::new(),
}
}
fn edit(mut self, hunk: &Hunk<Modification>) -> Result<Vec<ReviewComment>, Error> {
let mut input = String::new();
for line in hunk.to_unified_string()?.lines() {
writeln!(&mut input, "> {line}")?;
}
let output = term::Editor::comment()
.extension("diff")
.initial(input)?
.edit()?;
if let Some(output) = output {
let header = HunkHeader::try_from(hunk)?;
self.add_hunk(header, &output);
}
Ok(self.comments())
}
fn add_hunk(&mut self, hunk: HunkHeader, input: &str) -> &mut Self {
let lines = input.trim().lines().map(|l| l.trim());
let (mut old_line, mut new_line) = (hunk.old_line_no as usize, hunk.new_line_no as usize);
let (mut old_start, mut new_start) = (old_line, new_line);
let mut comment = String::new();
for line in lines {
if line.starts_with('>') {
if !comment.is_empty() {
self.add_comment(
&hunk,
&comment,
old_start..old_line - 1,
new_start..new_line - 1,
);
old_start = old_line - 1;
new_start = new_line - 1;
comment.clear();
}
match line.trim_start_matches('>').trim_start().chars().next() {
Some('-') => old_line += 1,
Some('+') => new_line += 1,
_ => {
old_line += 1;
new_line += 1;
}
}
} else {
comment.push_str(line);
comment.push('\n');
}
}
if !comment.is_empty() {
self.add_comment(
&hunk,
&comment,
old_start..old_line - 1,
new_start..new_line - 1,
);
}
self
}
fn add_comment(
&mut self,
hunk: &HunkHeader,
comment: &str,
mut old_range: Range<usize>,
mut new_range: Range<usize>,
) {
if comment.trim().is_empty() {
return;
}
if old_range.is_empty() && new_range.is_empty() {
old_range = hunk.old_line_no as usize..(hunk.old_line_no + hunk.old_size + 1) as usize;
new_range = hunk.new_line_no as usize..(hunk.new_line_no + hunk.new_size + 1) as usize;
}
let old_range = old_range
.is_empty()
.not()
.then_some(old_range)
.map(|range| CodeRange::Lines { range });
let new_range = (new_range)
.is_empty()
.not()
.then_some(new_range)
.map(|range| CodeRange::Lines { range });
self.comments.push(ReviewComment {
location: CodeLocation {
commit: self.commit,
path: self.path.clone(),
old: old_range,
new: new_range,
},
body: comment.trim().to_owned(),
});
}
fn comments(self) -> Vec<ReviewComment> {
self.comments
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_review_comments_basic() {
let input = r#"
> @@ -2559,18 +2560,18 @@ where
> // Only consider onion addresses if configured.
> AddressType::Onion => self.config.onion.is_some(),
> AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
> - })
> - .take(wanted)
> - .collect::<Vec<_>>(); // # -2564
Comment #1.
> + });
>
> - if available.len() < target {
> - log::warn!( # -2567
> + // Peers we are going to attempt connections to.
> + let connect = available.take(wanted).collect::<Vec<_>>();
Comment #2.
> + if connect.len() < wanted {
> + log::debug!(
> target: "service",
> - "Not enough available peers to connect to (available={}, target={target})",
> - available.len()
Comment #3.
> + "Not enough available peers to connect to (available={}, wanted={wanted})",
Comment #4.
> + connect.len()
> );
> }
> - for (id, ka) in available {
> + for (id, ka) in connect {
> self.connect(id, ka.addr.clone());
> }
> }
Comment #5.
"#;
let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
let path = PathBuf::from_str("main.rs").unwrap();
let expected = &[
(ReviewComment {
location: CodeLocation {
commit,
path: path.clone(),
old: Some(CodeRange::Lines { range: 2559..2565 }),
new: Some(CodeRange::Lines { range: 2560..2563 }),
},
body: "Comment #1.".to_owned(),
}),
(ReviewComment {
location: CodeLocation {
commit,
path: path.clone(),
old: Some(CodeRange::Lines { range: 2565..2568 }),
new: Some(CodeRange::Lines { range: 2563..2567 }),
},
body: "Comment #2.".to_owned(),
}),
(ReviewComment {
location: CodeLocation {
commit,
path: path.clone(),
old: Some(CodeRange::Lines { range: 2568..2571 }),
new: Some(CodeRange::Lines { range: 2567..2570 }),
},
body: "Comment #3.".to_owned(),
}),
(ReviewComment {
location: CodeLocation {
commit,
path: path.clone(),
old: None,
new: Some(CodeRange::Lines { range: 2570..2571 }),
},
body: "Comment #4.".to_owned(),
}),
(ReviewComment {
location: CodeLocation {
commit,
path: path.clone(),
old: Some(CodeRange::Lines { range: 2571..2577 }),
new: Some(CodeRange::Lines { range: 2571..2578 }),
},
body: "Comment #5.".to_owned(),
}),
];
let mut builder = CommentBuilder::new(commit, path.clone());
builder.add_hunk(
HunkHeader {
old_line_no: 2559,
old_size: 18,
new_line_no: 2560,
new_size: 18,
text: vec![],
},
input,
);
let actual = builder.comments();
assert_eq!(actual.len(), expected.len(), "{actual:#?}");
for (left, right) in actual.iter().zip(expected) {
assert_eq!(left, right);
}
}
#[test]
fn test_review_comments_multiline() {
let input = r#"
> @@ -2559,9 +2560,7 @@ where
> // Only consider onion addresses if configured.
> AddressType::Onion => self.config.onion.is_some(),
> AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
> - })
> - .take(wanted)
> - .collect::<Vec<_>>(); // # -2564
Blah blah blah blah blah blah blah.
Blah blah blah.
Blaah blaah blaah blaah blaah blaah blaah.
blaah blaah blaah.
Blaaah blaaah blaaah.
> + });
>
> - if available.len() < target {
> - log::warn!( # -2567
> + // Peers we are going to attempt connections to.
> + let connect = available.take(wanted).collect::<Vec<_>>();
Woof woof.
Woof.
Woof.
Woof.
"#;
let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
let path = PathBuf::from_str("main.rs").unwrap();
let expected = &[
(ReviewComment {
location: CodeLocation {
commit,
path: path.clone(),
old: Some(CodeRange::Lines { range: 2559..2565 }),
new: Some(CodeRange::Lines { range: 2560..2563 }),
},
body: r#"
Blah blah blah blah blah blah blah.
Blah blah blah.
Blaah blaah blaah blaah blaah blaah blaah.
blaah blaah blaah.
Blaaah blaaah blaaah.
"#
.trim()
.to_owned(),
}),
(ReviewComment {
location: CodeLocation {
commit,
path: path.clone(),
old: Some(CodeRange::Lines { range: 2565..2568 }),
new: Some(CodeRange::Lines { range: 2563..2567 }),
},
body: r#"
Woof woof.
Woof.
Woof.
Woof.
"#
.trim()
.to_owned(),
}),
];
let mut builder = CommentBuilder::new(commit, path.clone());
builder.add_hunk(
HunkHeader {
old_line_no: 2559,
old_size: 9,
new_line_no: 2560,
new_size: 7,
text: vec![],
},
input,
);
let actual = builder.comments();
assert_eq!(actual.len(), expected.len(), "{actual:#?}");
for (left, right) in actual.iter().zip(expected) {
assert_eq!(left, right);
}
}
#[test]
fn test_review_comments_before() {
let input = r#"
This is a top-level comment.
> @@ -2559,9 +2560,7 @@ where
> // Only consider onion addresses if configured.
> AddressType::Onion => self.config.onion.is_some(),
> AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
> - })
> - .take(wanted)
> - .collect::<Vec<_>>(); // # -2564
> + });
>
> - if available.len() < target {
> - log::warn!( # -2567
> + // Peers we are going to attempt connections to.
> + let connect = available.take(wanted).collect::<Vec<_>>();
"#;
let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
let path = PathBuf::from_str("main.rs").unwrap();
let expected = &[(ReviewComment {
location: CodeLocation {
commit,
path: path.clone(),
old: Some(CodeRange::Lines { range: 2559..2569 }),
new: Some(CodeRange::Lines { range: 2560..2568 }),
},
body: "This is a top-level comment.".to_owned(),
})];
let mut builder = CommentBuilder::new(commit, path.clone());
builder.add_hunk(
HunkHeader {
old_line_no: 2559,
old_size: 9,
new_line_no: 2560,
new_size: 7,
text: vec![],
},
input,
);
let actual = builder.comments();
assert_eq!(actual.len(), expected.len(), "{actual:#?}");
for (left, right) in actual.iter().zip(expected) {
assert_eq!(left, right);
}
}
#[test]
fn test_review_comments_split_hunk() {
let input = r#"
> @@ -2559,6 +2560,4 @@ where
> // Only consider onion addresses if configured.
> AddressType::Onion => self.config.onion.is_some(),
> AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
> - })
> - .take(wanted)
> - .collect::<Vec<_>>();
> + });
Comment on a split hunk.
"#;
let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
let path = PathBuf::from_str("main.rs").unwrap();
let expected = &[(ReviewComment {
location: CodeLocation {
commit,
path: path.clone(),
old: Some(CodeRange::Lines { range: 2564..2565 }),
new: Some(CodeRange::Lines { range: 2563..2564 }),
},
body: "Comment on a split hunk.".to_owned(),
})];
let mut builder = CommentBuilder::new(commit, path.clone());
builder.add_hunk(
HunkHeader {
old_line_no: 2559,
old_size: 6,
new_line_no: 2560,
new_size: 4,
text: vec![],
},
input,
);
let actual = builder.comments();
assert_eq!(actual.len(), expected.len(), "{actual:#?}");
for (left, right) in actual.iter().zip(expected) {
assert_eq!(left, right);
}
}
}