use std::path::PathBuf;
use crate::config::ForgeConfig;
use crate::model::comment::Comment;
use crate::model::{DiffFile, LineRange, LineSide};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubmitEvent {
Comment,
Approve,
RequestChanges,
Draft,
}
impl SubmitEvent {
pub fn github_event(self) -> Option<&'static str> {
match self {
SubmitEvent::Comment => Some("COMMENT"),
SubmitEvent::Approve => Some("APPROVE"),
SubmitEvent::RequestChanges => Some("REQUEST_CHANGES"),
SubmitEvent::Draft => None,
}
}
pub fn human_label(self) -> &'static str {
match self {
SubmitEvent::Comment => "Comment",
SubmitEvent::Approve => "Approve",
SubmitEvent::RequestChanges => "Request changes",
SubmitEvent::Draft => "Draft (pending review)",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GhSide {
Left,
Right,
}
impl GhSide {
pub fn as_str(self) -> &'static str {
match self {
GhSide::Left => "LEFT",
GhSide::Right => "RIGHT",
}
}
}
impl From<LineSide> for GhSide {
fn from(value: LineSide) -> Self {
match value {
LineSide::Old => GhSide::Left,
LineSide::New => GhSide::Right,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InlineComment {
pub path: PathBuf,
pub line: u32,
pub side: GhSide,
pub start_line: Option<u32>,
pub start_side: Option<GhSide>,
pub body: String,
pub comment_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UnmappableReason {
MixedSideRange,
FileLevelNoAnchor,
BinaryFile,
TooLargeFile,
LineNotInDiff,
}
impl UnmappableReason {
pub fn human_label(&self) -> &'static str {
match self {
UnmappableReason::MixedSideRange => "range spans both diff sides",
UnmappableReason::FileLevelNoAnchor => "no valid anchor line",
UnmappableReason::BinaryFile => "binary file",
UnmappableReason::TooLargeFile => "file too large",
UnmappableReason::LineNotInDiff => "line not in current diff",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MappedComment {
Inline(InlineComment),
Unmappable {
comment: Comment,
file: PathBuf,
reason: UnmappableReason,
},
}
fn build_inline_body(comment: &Comment, file_level: bool, config: &ForgeConfig) -> String {
if !config.comment_type_prefix {
return comment.content.clone();
}
let prefix = if file_level {
format!("[{ty}] File-level: ", ty = comment.comment_type.as_str())
} else {
format!("[{ty}] ", ty = comment.comment_type.as_str())
};
format!("{prefix}{body}", body = comment.content)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommentAnchor {
FileLevel,
Line { line: u32, side: LineSide },
Range,
}
pub fn map_comment(
comment: &Comment,
anchor: CommentAnchor,
file: &DiffFile,
config: &ForgeConfig,
) -> MappedComment {
let path = file.display_path().clone();
if file.is_binary {
return MappedComment::Unmappable {
comment: comment.clone(),
file: path,
reason: UnmappableReason::BinaryFile,
};
}
if file.is_too_large {
return MappedComment::Unmappable {
comment: comment.clone(),
file: path,
reason: UnmappableReason::TooLargeFile,
};
}
match anchor {
CommentAnchor::FileLevel => match file.first_valid_line(LineSide::New) {
Some(line) => MappedComment::Inline(InlineComment {
path,
line,
side: GhSide::Right,
start_line: None,
start_side: None,
body: build_inline_body(comment, true, config),
comment_id: comment.id.clone(),
}),
None => MappedComment::Unmappable {
comment: comment.clone(),
file: path,
reason: UnmappableReason::FileLevelNoAnchor,
},
},
CommentAnchor::Range => match comment.line_range {
Some(range) => map_range(comment, file, config, range),
None => MappedComment::Unmappable {
comment: comment.clone(),
file: path,
reason: UnmappableReason::MixedSideRange,
},
},
CommentAnchor::Line { line, side } => {
if !line_present_on_side(file, line, side) {
return MappedComment::Unmappable {
comment: comment.clone(),
file: path,
reason: UnmappableReason::LineNotInDiff,
};
}
MappedComment::Inline(InlineComment {
path,
line,
side: side.into(),
start_line: None,
start_side: None,
body: build_inline_body(comment, false, config),
comment_id: comment.id.clone(),
})
}
}
}
fn line_present_on_side(file: &DiffFile, line: u32, side: LineSide) -> bool {
for hunk in &file.hunks {
for dl in &hunk.lines {
let candidate = match side {
LineSide::Old => dl.old_lineno,
LineSide::New => dl.new_lineno,
};
if candidate == Some(line) {
return true;
}
}
}
false
}
fn map_range(
comment: &Comment,
file: &DiffFile,
config: &ForgeConfig,
range: LineRange,
) -> MappedComment {
let path = file.display_path().clone();
let side = match comment.side {
Some(s) => s,
None => {
return MappedComment::Unmappable {
comment: comment.clone(),
file: path,
reason: UnmappableReason::MixedSideRange,
};
}
};
if !range_endpoints_present(file, range, side) {
return MappedComment::Unmappable {
comment: comment.clone(),
file: path,
reason: UnmappableReason::MixedSideRange,
};
}
if range.is_single() {
return MappedComment::Inline(InlineComment {
path,
line: range.start,
side: side.into(),
start_line: None,
start_side: None,
body: build_inline_body(comment, false, config),
comment_id: comment.id.clone(),
});
}
MappedComment::Inline(InlineComment {
path,
line: range.end,
side: side.into(),
start_line: Some(range.start),
start_side: Some(side.into()),
body: build_inline_body(comment, false, config),
comment_id: comment.id.clone(),
})
}
fn range_endpoints_present(file: &DiffFile, range: LineRange, side: LineSide) -> bool {
let mut saw_start = false;
let mut saw_end = false;
for hunk in &file.hunks {
for line in &hunk.lines {
let lineno = match side {
LineSide::New => match line.origin {
crate::model::LineOrigin::Context | crate::model::LineOrigin::Addition => {
line.new_lineno
}
crate::model::LineOrigin::Deletion => None,
},
LineSide::Old => match line.origin {
crate::model::LineOrigin::Deletion => line.old_lineno,
_ => None,
},
};
if let Some(n) = lineno {
if n == range.start {
saw_start = true;
}
if n == range.end {
saw_end = true;
}
}
}
}
saw_start && saw_end
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreflightResult {
pub event: SubmitEvent,
pub mappable: Vec<InlineComment>,
pub unmappable: Vec<UnmappableItem>,
pub commit_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnmappableItem {
pub comment: Comment,
pub file: PathBuf,
pub reason: UnmappableReason,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ResolverAction {
#[default]
MoveToSummary,
Omit,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MovedToSummaryItem {
pub comment: Comment,
pub file: PathBuf,
}
pub fn build_review_body(
review_level: &[Comment],
moved_to_summary: &[MovedToSummaryItem],
config: &ForgeConfig,
) -> String {
let mut sections: Vec<String> = Vec::new();
if !review_level.is_empty() {
let mut block = String::new();
for (i, c) in review_level.iter().enumerate() {
if i > 0 {
block.push_str("\n\n");
}
if config.comment_type_prefix {
block.push_str(&format!("[{}] ", c.comment_type.as_str()));
}
block.push_str(&c.content);
}
sections.push(block);
}
if !moved_to_summary.is_empty() {
let mut block = String::from("## Unplaced comments\n");
for item in moved_to_summary {
let prefix = if config.comment_type_prefix {
format!("[{}] ", item.comment.comment_type.as_str())
} else {
String::new()
};
let path = item.file.display();
block.push_str(&format!(
"- {prefix}{path}: {body}\n",
body = item.comment.content
));
}
if block.ends_with('\n') {
block.pop();
}
sections.push(block);
}
if config.review_footer {
sections.push(REVIEW_FOOTER.to_string());
}
sections.join("\n\n")
}
pub const REVIEW_FOOTER: &str =
"<sub>Reviewed with [tuicr](https://github.com/agavra/tuicr).</sub>";
#[cfg(test)]
mod tests {
use super::*;
use crate::model::comment::{Comment, CommentType, LineContext, LineRange, LineSide};
use crate::model::diff_types::{DiffHunk, DiffLine, FileStatus, LineOrigin};
use std::path::PathBuf;
fn line(origin: LineOrigin, new: Option<u32>, old: Option<u32>) -> DiffLine {
DiffLine {
origin,
content: String::new(),
old_lineno: old,
new_lineno: new,
highlighted_spans: None,
}
}
fn hunk(lines: Vec<DiffLine>) -> DiffHunk {
DiffHunk {
header: "@@".to_string(),
old_start: 1,
old_count: 0,
new_start: 1,
new_count: 0,
lines,
}
}
fn file_with_hunks(hunks: Vec<DiffHunk>) -> DiffFile {
DiffFile {
old_path: Some(PathBuf::from("src/lib.rs")),
new_path: Some(PathBuf::from("src/lib.rs")),
status: FileStatus::Modified,
hunks,
is_binary: false,
is_too_large: false,
is_commit_message: false,
content_hash: 0,
}
}
fn typical_file() -> DiffFile {
file_with_hunks(vec![hunk(vec![
line(LineOrigin::Context, Some(10), Some(10)),
line(LineOrigin::Deletion, None, Some(11)),
line(LineOrigin::Addition, Some(11), None),
line(LineOrigin::Context, Some(12), Some(12)),
])])
}
fn default_config() -> ForgeConfig {
ForgeConfig::default()
}
fn comment_with_line(side: LineSide, new: Option<u32>, old: Option<u32>) -> Comment {
let mut c = Comment::new("needs work".to_string(), CommentType::Issue, Some(side));
c.line_context = Some(LineContext {
new_line: new,
old_line: old,
content: String::new(),
});
c
}
fn comment_range(side: LineSide, range: LineRange) -> Comment {
Comment::new_with_range("ranged".to_string(), CommentType::Note, Some(side), range)
}
fn comment_file_level() -> Comment {
Comment::new("module is messy".to_string(), CommentType::Note, None)
}
fn anchor_from(comment: &Comment) -> CommentAnchor {
if comment.line_range.is_some() {
return CommentAnchor::Range;
}
let side = comment.side.unwrap_or_default();
let line = comment.line_context.as_ref().and_then(|ctx| match side {
LineSide::New => ctx.new_line,
LineSide::Old => ctx.old_line,
});
match line {
Some(l) => CommentAnchor::Line { line: l, side },
None => CommentAnchor::FileLevel,
}
}
#[test]
fn should_return_first_addition_line_on_new_side() {
let file = file_with_hunks(vec![hunk(vec![
line(LineOrigin::Deletion, None, Some(11)),
line(LineOrigin::Addition, Some(20), None),
line(LineOrigin::Context, Some(21), Some(13)),
])]);
assert_eq!(file.first_valid_line(LineSide::New), Some(20));
}
#[test]
fn should_return_first_deletion_line_on_old_side() {
let file = file_with_hunks(vec![hunk(vec![
line(LineOrigin::Addition, Some(20), None),
line(LineOrigin::Deletion, None, Some(11)),
line(LineOrigin::Deletion, None, Some(12)),
])]);
assert_eq!(file.first_valid_line(LineSide::Old), Some(11));
}
#[test]
fn should_return_none_for_binary_file_first_valid_line() {
let mut file = typical_file();
file.is_binary = true;
assert!(file.first_valid_line(LineSide::New).is_none());
}
#[test]
fn should_return_none_for_too_large_file_first_valid_line() {
let mut file = typical_file();
file.is_too_large = true;
assert!(file.first_valid_line(LineSide::New).is_none());
}
#[test]
fn should_map_single_addition_line_to_right_side() {
let comment = comment_with_line(LineSide::New, Some(11), None);
let mapped = map_comment(
&comment,
anchor_from(&comment),
&typical_file(),
&default_config(),
);
match mapped {
MappedComment::Inline(inline) => {
assert_eq!(inline.line, 11);
assert_eq!(inline.side, GhSide::Right);
assert_eq!(inline.start_line, None);
assert_eq!(inline.start_side, None);
assert!(inline.body.starts_with("[ISSUE] "));
}
other => panic!("expected Inline, got {other:?}"),
}
}
#[test]
fn should_map_single_context_line_to_right_side() {
let comment = comment_with_line(LineSide::New, Some(10), Some(10));
let mapped = map_comment(
&comment,
anchor_from(&comment),
&typical_file(),
&default_config(),
);
assert!(matches!(
mapped,
MappedComment::Inline(InlineComment {
side: GhSide::Right,
line: 10,
..
})
));
}
#[test]
fn should_map_single_deletion_line_to_left_side() {
let comment = comment_with_line(LineSide::Old, None, Some(11));
let mapped = map_comment(
&comment,
anchor_from(&comment),
&typical_file(),
&default_config(),
);
match mapped {
MappedComment::Inline(inline) => {
assert_eq!(inline.line, 11);
assert_eq!(inline.side, GhSide::Left);
}
other => panic!("expected Inline, got {other:?}"),
}
}
#[test]
fn should_map_new_side_range_to_right_start_and_end() {
let file = file_with_hunks(vec![hunk(vec![
line(LineOrigin::Addition, Some(10), None),
line(LineOrigin::Addition, Some(11), None),
line(LineOrigin::Addition, Some(12), None),
])]);
let comment = comment_range(LineSide::New, LineRange::new(10, 12));
let mapped = map_comment(&comment, anchor_from(&comment), &file, &default_config());
match mapped {
MappedComment::Inline(inline) => {
assert_eq!(inline.line, 12);
assert_eq!(inline.start_line, Some(10));
assert_eq!(inline.side, GhSide::Right);
assert_eq!(inline.start_side, Some(GhSide::Right));
}
other => panic!("expected Inline, got {other:?}"),
}
}
#[test]
fn should_map_old_side_range_to_left_start_and_end() {
let file = file_with_hunks(vec![hunk(vec![
line(LineOrigin::Deletion, None, Some(20)),
line(LineOrigin::Deletion, None, Some(21)),
line(LineOrigin::Deletion, None, Some(22)),
])]);
let comment = comment_range(LineSide::Old, LineRange::new(20, 22));
let mapped = map_comment(&comment, anchor_from(&comment), &file, &default_config());
match mapped {
MappedComment::Inline(inline) => {
assert_eq!(inline.line, 22);
assert_eq!(inline.start_line, Some(20));
assert_eq!(inline.side, GhSide::Left);
assert_eq!(inline.start_side, Some(GhSide::Left));
}
other => panic!("expected Inline, got {other:?}"),
}
}
#[test]
fn should_flatten_single_line_range_to_inline_without_start_fields() {
let file = file_with_hunks(vec![hunk(vec![line(LineOrigin::Addition, Some(15), None)])]);
let comment = comment_range(LineSide::New, LineRange::single(15));
let mapped = map_comment(&comment, anchor_from(&comment), &file, &default_config());
match mapped {
MappedComment::Inline(inline) => {
assert_eq!(inline.line, 15);
assert_eq!(inline.start_line, None);
assert_eq!(inline.start_side, None);
}
other => panic!("expected Inline, got {other:?}"),
}
}
#[test]
fn should_mark_mixed_side_range_as_unmappable() {
let file = file_with_hunks(vec![hunk(vec![
line(LineOrigin::Deletion, None, Some(20)),
line(LineOrigin::Deletion, None, Some(21)),
line(LineOrigin::Deletion, None, Some(22)),
])]);
let comment = comment_range(LineSide::New, LineRange::new(20, 22));
let mapped = map_comment(&comment, anchor_from(&comment), &file, &default_config());
match mapped {
MappedComment::Unmappable { reason, .. } => {
assert_eq!(reason, UnmappableReason::MixedSideRange);
}
other => panic!("expected Unmappable, got {other:?}"),
}
}
#[test]
fn should_anchor_file_level_to_first_valid_new_line() {
let comment = comment_file_level();
let mapped = map_comment(
&comment,
anchor_from(&comment),
&typical_file(),
&default_config(),
);
match mapped {
MappedComment::Inline(inline) => {
assert_eq!(inline.line, 10);
assert_eq!(inline.side, GhSide::Right);
assert!(inline.body.starts_with("[NOTE] File-level: "));
}
other => panic!("expected Inline, got {other:?}"),
}
}
#[test]
fn should_mark_file_level_without_new_anchor_as_unmappable() {
let file = file_with_hunks(vec![hunk(vec![line(LineOrigin::Deletion, None, Some(5))])]);
let comment = comment_file_level();
let mapped = map_comment(&comment, anchor_from(&comment), &file, &default_config());
match mapped {
MappedComment::Unmappable { reason, .. } => {
assert_eq!(reason, UnmappableReason::FileLevelNoAnchor);
}
other => panic!("expected Unmappable, got {other:?}"),
}
}
#[test]
fn should_mark_binary_file_comment_as_unmappable() {
let mut file = typical_file();
file.is_binary = true;
let comment = comment_with_line(LineSide::New, Some(11), None);
let mapped = map_comment(&comment, anchor_from(&comment), &file, &default_config());
assert!(matches!(
mapped,
MappedComment::Unmappable {
reason: UnmappableReason::BinaryFile,
..
}
));
}
#[test]
fn should_mark_too_large_file_comment_as_unmappable() {
let mut file = typical_file();
file.is_too_large = true;
let comment = comment_file_level();
let mapped = map_comment(&comment, anchor_from(&comment), &file, &default_config());
assert!(matches!(
mapped,
MappedComment::Unmappable {
reason: UnmappableReason::TooLargeFile,
..
}
));
}
#[test]
fn should_omit_type_prefix_when_config_disables_it() {
let comment = comment_with_line(LineSide::New, Some(11), None);
let cfg = ForgeConfig {
comment_type_prefix: false,
review_footer: true,
};
let mapped = map_comment(&comment, anchor_from(&comment), &typical_file(), &cfg);
match mapped {
MappedComment::Inline(inline) => {
assert!(!inline.body.contains("[ISSUE]"));
assert_eq!(inline.body, "needs work");
}
other => panic!("expected Inline, got {other:?}"),
}
}
fn note(content: &str) -> Comment {
Comment::new(content.to_string(), CommentType::Note, None)
}
#[test]
fn should_return_empty_body_when_no_inputs_and_footer_disabled() {
let cfg = ForgeConfig {
comment_type_prefix: true,
review_footer: false,
};
let body = build_review_body(&[], &[], &cfg);
assert_eq!(body, "");
}
#[test]
fn should_return_footer_only_when_no_review_comments_and_no_summary() {
let body = build_review_body(&[], &[], &default_config());
assert_eq!(body, REVIEW_FOOTER);
}
#[test]
fn should_render_review_level_comments_with_type_prefix() {
let comments = vec![note("first"), note("second")];
let body = build_review_body(&comments, &[], &default_config());
assert!(body.starts_with("[NOTE] first\n\n[NOTE] second"));
assert!(body.ends_with(REVIEW_FOOTER));
}
#[test]
fn should_render_unplaced_comments_section_before_footer() {
let item = MovedToSummaryItem {
comment: Comment::new("kaboom".to_string(), CommentType::Issue, None),
file: PathBuf::from("src/lib.rs"),
};
let body = build_review_body(&[], &[item], &default_config());
assert!(body.contains("## Unplaced comments"));
assert!(body.contains("- [ISSUE] src/lib.rs: kaboom"));
assert!(body.ends_with(REVIEW_FOOTER));
}
#[test]
fn should_render_all_three_sections_in_order() {
let review = vec![note("top")];
let summary = vec![MovedToSummaryItem {
comment: Comment::new("middle".to_string(), CommentType::Note, None),
file: PathBuf::from("a.rs"),
}];
let body = build_review_body(&review, &summary, &default_config());
let top = body.find("[NOTE] top").expect("review comment");
let middle = body.find("## Unplaced comments").expect("unplaced section");
let bottom = body.find(REVIEW_FOOTER).expect("footer");
assert!(top < middle && middle < bottom, "section ordering: {body}");
}
#[test]
fn should_omit_type_prefix_in_body_when_disabled() {
let cfg = ForgeConfig {
comment_type_prefix: false,
review_footer: false,
};
let comments = vec![note("just text")];
let body = build_review_body(&comments, &[], &cfg);
assert_eq!(body, "just text");
}
#[test]
fn should_map_each_event_to_correct_github_field() {
assert_eq!(SubmitEvent::Comment.github_event(), Some("COMMENT"));
assert_eq!(SubmitEvent::Approve.github_event(), Some("APPROVE"));
assert_eq!(
SubmitEvent::RequestChanges.github_event(),
Some("REQUEST_CHANGES")
);
assert_eq!(SubmitEvent::Draft.github_event(), None);
}
}