use std::collections::HashSet;
use std::fmt::Write;
use std::io::Write as IoWrite;
use arboard::Clipboard;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use crate::app::{CommentTypeDefinition, DiffSource};
use crate::error::{Result, TuicrError};
use crate::forge::remote_comments::{
PrCommentsVisibility, RemoteReviewThread, filter_threads, group_threads_by_path,
};
use crate::model::{CommentType, LineRange, LineSide, ReviewSession};
use crate::slug::short_sha;
type CommentEntry<'a> = (
String,
Option<LineRange>,
Option<LineSide>,
String,
&'a str,
Option<&'a str>,
);
pub fn generate_export_content(
session: &ReviewSession,
diff_source: &DiffSource,
comment_types: &[CommentTypeDefinition],
show_legend: bool,
remote_threads: &[RemoteReviewThread],
session_slug: Option<&str>,
) -> Result<String> {
let has_remote = matches!(diff_source, DiffSource::PullRequest(_))
&& !filter_threads(remote_threads, PrCommentsVisibility::Unresolved).is_empty();
if !session.has_comments() && !has_remote {
return Err(TuicrError::NoComments);
}
Ok(generate_markdown(
session,
diff_source,
comment_types,
show_legend,
remote_threads,
session_slug,
))
}
pub fn export_to_clipboard(
session: &ReviewSession,
diff_source: &DiffSource,
comment_types: &[CommentTypeDefinition],
show_legend: bool,
remote_threads: &[RemoteReviewThread],
session_slug: Option<&str>,
) -> Result<String> {
let content = generate_export_content(
session,
diff_source,
comment_types,
show_legend,
remote_threads,
session_slug,
)?;
let via_terminal = copy_text_to_clipboard(&content)?;
Ok(if via_terminal {
"Review copied to clipboard (via terminal)".to_string()
} else {
"Review copied to clipboard".to_string()
})
}
pub fn copy_text_to_clipboard(text: &str) -> Result<bool> {
if cfg!(target_os = "macos") && try_clipboard_cmd("pbcopy", &[], text) {
return Ok(false);
}
if should_prefer_osc52() {
copy_osc52(text)?;
return Ok(true);
}
if try_copy_via_subprocess(text) {
return Ok(false);
}
match Clipboard::new().and_then(|mut cb| cb.set_text(text)) {
Ok(_) => Ok(false),
Err(_) => {
copy_osc52(text)?;
Ok(true)
}
}
}
fn try_copy_via_subprocess(text: &str) -> bool {
let session = std::env::var("XDG_SESSION_TYPE");
if session.is_err() {
return false;
}
let session = session.unwrap();
if session == "wayland" {
try_clipboard_cmd("wl-copy", &[], text)
} else if session == "x11" {
try_clipboard_cmd("xclip", &["-selection", "clipboard"], text)
} else {
false
}
}
fn try_clipboard_cmd(program: &str, args: &[&str], text: &str) -> bool {
use std::process::{Command, Stdio};
let Ok(mut child) = Command::new(program)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
else {
return false;
};
if let Some(mut stdin) = child.stdin.take() {
let _ = std::io::Write::write_all(&mut stdin, text.as_bytes());
}
matches!(child.wait(), Ok(s) if s.success())
}
fn should_prefer_osc52() -> bool {
std::env::var("TMUX").is_ok()
|| std::env::var("SSH_TTY").is_ok()
|| std::env::var("ZELLIJ").is_ok()
}
fn copy_osc52(text: &str) -> Result<()> {
if std::env::var("TMUX").is_ok() {
copy_via_tmux(text)
} else {
let mut stdout = std::io::stdout().lock();
write_osc52(&mut stdout, text)
}
}
fn copy_via_tmux(text: &str) -> Result<()> {
use std::process::{Command, Stdio};
let mut child = Command::new("tmux")
.args(["load-buffer", "-w", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| TuicrError::Clipboard(format!("Failed to run tmux: {e}")))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(text.as_bytes())
.map_err(|e| TuicrError::Clipboard(format!("Failed to write to tmux: {e}")))?;
}
let status = child
.wait()
.map_err(|e| TuicrError::Clipboard(format!("tmux load-buffer failed: {e}")))?;
if !status.success() {
return Err(TuicrError::Clipboard(
"tmux load-buffer exited with error".to_string(),
));
}
Ok(())
}
fn write_osc52<W: IoWrite>(writer: &mut W, text: &str) -> Result<()> {
let encoded = BASE64.encode(text);
write!(writer, "\x1b]52;c;{encoded}\x07")
.map_err(|e| TuicrError::Clipboard(format!("Failed to write OSC 52: {e}")))?;
writer
.flush()
.map_err(|e| TuicrError::Clipboard(format!("Failed to flush: {e}")))?;
Ok(())
}
fn review_scope_label(diff_source: &DiffSource) -> String {
let scope = match diff_source {
DiffSource::WorkingTree => "working tree changes".to_string(),
DiffSource::StagedAndUnstaged => "staged + unstaged changes".to_string(),
DiffSource::Staged => "staged changes".to_string(),
DiffSource::Unstaged => "unstaged changes".to_string(),
DiffSource::CommitRange(_) => "selected commit range".to_string(),
DiffSource::StagedUnstagedAndCommits(_) => {
"selected commit range + staged/unstaged changes".to_string()
}
DiffSource::PullRequest(pr) => format!(
"pull request {}#{}",
pr.key.repository.display_name(),
pr.key.number
),
};
format!("Review Comment (scope: {scope})")
}
fn generate_markdown(
session: &ReviewSession,
diff_source: &DiffSource,
comment_types: &[CommentTypeDefinition],
show_legend: bool,
remote_threads: &[RemoteReviewThread],
session_slug: Option<&str>,
) -> String {
let mut md = String::new();
if let Some(slug) = session_slug {
let _ = writeln!(md, "## Session: {slug}");
let _ = writeln!(md);
}
let _ = writeln!(
md,
"I reviewed your code and have the following comments. Please address them."
);
let _ = writeln!(md);
match diff_source {
DiffSource::WorkingTree => {}
DiffSource::Staged => {
let _ = writeln!(md, "Reviewing staged changes");
let _ = writeln!(md);
}
DiffSource::Unstaged => {
let _ = writeln!(md, "Reviewing unstaged changes");
let _ = writeln!(md);
}
DiffSource::StagedAndUnstaged => {
let _ = writeln!(md, "Reviewing staged + unstaged changes");
let _ = writeln!(md);
}
DiffSource::CommitRange(commits) => {
if commits.len() == 1 {
let _ = writeln!(
md,
"Reviewing commit: {}",
&commits[0][..7.min(commits[0].len())]
);
} else {
let short_ids: Vec<&str> = commits.iter().map(|c| &c[..7.min(c.len())]).collect();
let _ = writeln!(md, "Reviewing commits: {}", short_ids.join(", "));
}
let _ = writeln!(md);
}
DiffSource::StagedUnstagedAndCommits(commits) => {
let short_ids: Vec<&str> = commits.iter().map(|c| &c[..7.min(c.len())]).collect();
let _ = writeln!(
md,
"Reviewing staged + unstaged + commits: {}",
short_ids.join(", ")
);
let _ = writeln!(md);
}
DiffSource::PullRequest(pr) => {
let short = pr.key.short_head();
let _ = writeln!(
md,
"Reviewing pull request {}#{}: {}",
pr.key.repository.display_name(),
pr.key.number,
pr.title
);
let _ = writeln!(md, "URL: {}", pr.url);
let _ = writeln!(md, "Head: {short}");
let _ = writeln!(md);
}
}
if show_legend {
let used_ids = collect_used_comment_type_ids(session);
let legend = comment_types
.iter()
.filter(|ct| ct.id != CommentType::NONE_ID)
.filter(|ct| used_ids.is_empty() || used_ids.contains(&ct.id))
.map(|comment_type| {
let definition = comment_type
.definition
.as_deref()
.unwrap_or(comment_type.id.as_str());
format!(
"{} ({})",
comment_type.label.to_ascii_uppercase(),
definition
)
})
.collect::<Vec<_>>()
.join(", ");
if !legend.is_empty() {
let _ = writeln!(md, "Comment types: {legend}");
let _ = writeln!(md);
}
}
if let Some(notes) = &session.session_notes {
let _ = writeln!(md, "Summary: {notes}");
let _ = writeln!(md);
}
let mut all_comments: Vec<CommentEntry> = Vec::new();
let review_comment_location = review_scope_label(diff_source);
for comment in &session.review_comments {
all_comments.push((
review_comment_location.clone(),
None,
None,
export_comment_type_label(&comment.comment_type, comment_types),
&comment.content,
None,
));
}
let mut files: Vec<_> = session.files.iter().collect();
files.sort_by_key(|(path, _)| path.to_string_lossy().to_string());
for (path, review) in files {
let path_str = path.display().to_string();
for comment in &review.file_comments {
all_comments.push((
path_str.clone(),
None,
None,
export_comment_type_label(&comment.comment_type, comment_types),
&comment.content,
comment.commit_id.as_deref(),
));
}
let mut line_comments: Vec<_> = review.line_comments.iter().collect();
line_comments.sort_by_key(|(line, _)| *line);
for (line, comments) in line_comments {
for comment in comments {
let line_range = comment
.line_range
.or_else(|| Some(LineRange::single(*line)));
all_comments.push((
path_str.clone(),
line_range,
comment.side,
export_comment_type_label(&comment.comment_type, comment_types),
&comment.content,
comment.commit_id.as_deref(),
));
}
}
}
let mut local_section_written = false;
if !all_comments.is_empty() {
let _ = writeln!(md, "## Local tuicr Comments");
let _ = writeln!(md);
local_section_written = true;
}
for (i, (file, line_range, side, comment_type, content, commit_id)) in
all_comments.iter().enumerate()
{
let number = i + 1;
let location = match (line_range, side) {
(Some(range), Some(LineSide::Old)) if range.is_single() => {
format!("`{}:~{}`", file, range.start)
}
(Some(range), Some(LineSide::Old)) => {
format!("`{}:~{}-~{}`", file, range.start, range.end)
}
(Some(range), _) if range.is_single() => {
format!("`{}:{}`", file, range.start)
}
(Some(range), _) => {
format!("`{}:{}-{}`", file, range.start, range.end)
}
(None, _) => format!("`{file}`"),
};
let commit_suffix = match commit_id {
Some(sha) => format!(" (commit {})", short_sha(sha)),
None => String::new(),
};
let marker = format!("{number}.");
let continuation_indent = " ".repeat(marker.len() + 1);
let mut content_lines = content.split('\n').map(|line| line.trim_end_matches('\r'));
let first_line = content_lines.next().unwrap_or_default();
let type_marker = if comment_type.is_empty() {
String::new()
} else {
format!("**[{comment_type}]** ")
};
let _ = writeln!(
md,
"{marker} {type_marker}{location}{commit_suffix} - {first_line}"
);
for line in content_lines {
let _ = writeln!(md, "{continuation_indent}{line}");
}
}
if matches!(diff_source, DiffSource::PullRequest(_)) {
let unresolved: Vec<&RemoteReviewThread> =
filter_threads(remote_threads, PrCommentsVisibility::Unresolved);
if !unresolved.is_empty() {
if local_section_written {
let _ = writeln!(md);
}
let _ = writeln!(md, "## Existing GitHub Comments");
let _ = writeln!(md);
let owned_unresolved: Vec<RemoteReviewThread> =
unresolved.iter().map(|t| (*t).clone()).collect();
let groups = group_threads_by_path(&owned_unresolved);
let mut thread_n = 1;
for (path, threads) in groups {
let _ = writeln!(md, "### `{path}`");
let _ = writeln!(md);
for thread in threads {
if let Some(root) = thread.root() {
let author = root.author.as_deref().unwrap_or("unknown");
let line_marker = thread.line.map(|l| format!(":{l}")).unwrap_or_default();
let _ = writeln!(
md,
"{thread_n}. `{path}{line_marker}` @{author} - {body}",
body = root.body
);
if !root.url.is_empty() {
let _ = writeln!(md, " <{}>", root.url);
}
for reply in thread.replies() {
let reply_author = reply.author.as_deref().unwrap_or("unknown");
let _ =
writeln!(md, " - @{reply_author} - {body}", body = reply.body);
}
thread_n += 1;
}
}
let _ = writeln!(md);
}
}
}
md
}
fn collect_used_comment_type_ids(session: &ReviewSession) -> HashSet<String> {
let mut ids = HashSet::new();
for c in &session.review_comments {
ids.insert(c.comment_type.id().to_string());
}
for review in session.files.values() {
for c in &review.file_comments {
ids.insert(c.comment_type.id().to_string());
}
for comments in review.line_comments.values() {
for c in comments {
ids.insert(c.comment_type.id().to_string());
}
}
}
ids
}
fn export_comment_type_label(
comment_type: &CommentType,
comment_types: &[CommentTypeDefinition],
) -> String {
if comment_type.is_none() {
return String::new();
}
if let Some(definition) = comment_types
.iter()
.find(|definition| definition.id == comment_type.id())
{
return definition.label.to_ascii_uppercase();
}
comment_type.as_str()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::CommentTypeDefinition;
use crate::model::{Comment, CommentType, FileStatus, LineRange, LineSide, SessionDiffSource};
use std::path::PathBuf;
fn comment_types() -> Vec<CommentTypeDefinition> {
vec![
CommentTypeDefinition {
id: "note".to_string(),
label: "note".to_string(),
definition: Some("observations".to_string()),
color: None,
},
CommentTypeDefinition {
id: "suggestion".to_string(),
label: "suggestion".to_string(),
definition: Some("improvements".to_string()),
color: None,
},
CommentTypeDefinition {
id: "issue".to_string(),
label: "issue".to_string(),
definition: Some("problems to fix".to_string()),
color: None,
},
CommentTypeDefinition {
id: "praise".to_string(),
label: "praise".to_string(),
definition: Some("positive feedback".to_string()),
color: None,
},
]
}
fn create_test_session() -> ReviewSession {
let mut session = ReviewSession::new(
PathBuf::from("/tmp/test-repo"),
"abc1234def".to_string(),
Some("main".to_string()),
SessionDiffSource::WorkingTree,
);
session.add_file(PathBuf::from("src/main.rs"), FileStatus::Modified, 0);
if let Some(review) = session.get_file_mut(&PathBuf::from("src/main.rs")) {
review.reviewed = true;
review.add_file_comment(Comment::new(
"Consider adding documentation".to_string(),
CommentType::from_id("suggestion"),
None,
));
review.add_line_comment(
42,
Comment::new(
"Magic number should be a constant".to_string(),
CommentType::from_id("issue"),
Some(LineSide::New),
),
);
}
session
}
#[test]
fn should_include_session_slug_header_when_provided() {
let session = create_test_session();
let diff_source = DiffSource::WorkingTree;
let markdown = generate_markdown(
&session,
&diff_source,
&comment_types(),
true,
&[],
Some("agavra/tuicr@main/worktree"),
);
assert!(
markdown.contains("## Session: agavra/tuicr@main/worktree"),
"expected slug header in:\n{markdown}"
);
}
#[test]
fn should_omit_session_slug_header_when_absent() {
let session = create_test_session();
let diff_source = DiffSource::WorkingTree;
let markdown = generate_markdown(&session, &diff_source, &comment_types(), true, &[], None);
assert!(!markdown.contains("## Session:"));
}
#[test]
fn should_generate_valid_markdown() {
let session = create_test_session();
let diff_source = DiffSource::WorkingTree;
let markdown = generate_markdown(&session, &diff_source, &comment_types(), true, &[], None);
assert!(markdown.contains("I reviewed your code and have the following comments"));
assert!(
markdown.contains("Comment types: SUGGESTION (improvements), ISSUE (problems to fix)")
);
assert!(!markdown.contains("NOTE"));
assert!(!markdown.contains("PRAISE"));
assert!(markdown.contains("[SUGGESTION]"));
assert!(markdown.contains("`src/main.rs`"));
assert!(markdown.contains("Consider adding documentation"));
assert!(markdown.contains("[ISSUE]"));
assert!(markdown.contains("`src/main.rs:42`"));
assert!(markdown.contains("Magic number"));
}
#[test]
fn should_keep_commit_identity_for_commit_message_comments() {
let mut session = ReviewSession::new(
PathBuf::from("/tmp/test-repo"),
"abc1234def".to_string(),
Some("main".to_string()),
SessionDiffSource::CommitRange,
);
let first = PathBuf::from("Commit Message (ed50028)");
let second = PathBuf::from("Commit Message (c17beb2)");
session.add_file(first.clone(), FileStatus::Added, 0);
session.add_file(second.clone(), FileStatus::Added, 0);
if let Some(review) = session.get_file_mut(&first) {
review.add_line_comment(
1,
Comment::new(
"We do not need this commit".to_string(),
CommentType::from_id("note"),
Some(LineSide::New),
),
);
}
if let Some(review) = session.get_file_mut(&second) {
review.add_line_comment(
6,
Comment::new(
"This is wrong".to_string(),
CommentType::from_id("note"),
Some(LineSide::New),
),
);
}
let diff_source =
DiffSource::CommitRange(vec!["ed50028".to_string(), "c17beb2".to_string()]);
let markdown = generate_markdown(&session, &diff_source, &comment_types(), true, &[], None);
assert!(
markdown.contains("`Commit Message (ed50028):1`"),
"expected first commit's message comment to be attributable in:\n{markdown}"
);
assert!(
markdown.contains("`Commit Message (c17beb2):6`"),
"expected second commit's message comment to be attributable in:\n{markdown}"
);
}
#[test]
fn should_use_configured_label_and_definition_in_export() {
let mut session = ReviewSession::new(
PathBuf::from("/tmp/test-repo"),
"abc1234def".to_string(),
Some("main".to_string()),
SessionDiffSource::WorkingTree,
);
session.add_file(PathBuf::from("src/main.rs"), FileStatus::Modified, 0);
if let Some(review) = session.get_file_mut(&PathBuf::from("src/main.rs")) {
review.add_file_comment(Comment::new(
"Needs clarification".to_string(),
CommentType::from_id("note"),
None,
));
}
let custom_types = vec![CommentTypeDefinition {
id: "note".to_string(),
label: "question".to_string(),
definition: Some("ask for clarification".to_string()),
color: None,
}];
let markdown = generate_markdown(
&session,
&DiffSource::WorkingTree,
&custom_types,
true,
&[],
None,
);
assert!(markdown.contains("Comment types: QUESTION (ask for clarification)"));
assert!(markdown.contains("**[QUESTION]**"));
}
#[test]
fn should_number_comments_sequentially() {
let session = create_test_session();
let diff_source = DiffSource::WorkingTree;
let markdown = generate_markdown(&session, &diff_source, &comment_types(), true, &[], None);
assert!(markdown.contains("1. **[SUGGESTION]**"));
assert!(markdown.contains("2. **[ISSUE]**"));
}
#[test]
fn should_indent_multiline_comments_under_single_digit_list_marker() {
let mut session = ReviewSession::new(
PathBuf::from("/tmp/test-repo"),
"abc1234def".to_string(),
Some("main".to_string()),
SessionDiffSource::WorkingTree,
);
session.add_file(PathBuf::from("src/main.rs"), FileStatus::Modified, 0);
if let Some(review) = session.get_file_mut(&PathBuf::from("src/main.rs")) {
review.add_file_comment(Comment::new(
"foo\nbar".to_string(),
CommentType::from_id("suggestion"),
None,
));
}
let markdown = generate_markdown(
&session,
&DiffSource::WorkingTree,
&comment_types(),
true,
&[],
None,
);
let expected = "\
1. **[SUGGESTION]** `src/main.rs` - foo
bar";
assert!(
markdown.contains(expected),
"expected multiline comment continuation to align under list text:\n{markdown}"
);
}
#[test]
fn should_indent_multiline_comments_under_double_digit_list_marker() {
let mut session = ReviewSession::new(
PathBuf::from("/tmp/test-repo"),
"abc1234def".to_string(),
Some("main".to_string()),
SessionDiffSource::WorkingTree,
);
session.add_file(PathBuf::from("src/main.rs"), FileStatus::Modified, 0);
if let Some(review) = session.get_file_mut(&PathBuf::from("src/main.rs")) {
for i in 0..9 {
review.add_file_comment(Comment::new(
format!("comment {i}"),
CommentType::from_id("note"),
None,
));
}
review.add_file_comment(Comment::new(
"foo\nbar".to_string(),
CommentType::from_id("suggestion"),
None,
));
}
let markdown = generate_markdown(
&session,
&DiffSource::WorkingTree,
&comment_types(),
true,
&[],
None,
);
let expected = "\
10. **[SUGGESTION]** `src/main.rs` - foo
bar";
assert!(
markdown.contains(expected),
"expected double-digit continuation to align under list text:\n{markdown}"
);
}
#[test]
fn should_include_review_comments_in_export() {
let mut session = create_test_session();
session.review_comments.push(Comment::new(
"Please split this into smaller commits".to_string(),
CommentType::from_id("note"),
None,
));
let markdown = generate_markdown(
&session,
&DiffSource::WorkingTree,
&comment_types(),
true,
&[],
None,
);
assert!(markdown
.contains("`Review Comment (scope: working tree changes)` - Please split this into smaller commits"));
}
#[test]
fn should_include_commit_range_scope_for_review_comments() {
let mut session = create_test_session();
session.review_comments.push(Comment::new(
"High-level concern across commits".to_string(),
CommentType::from_id("issue"),
None,
));
let markdown = generate_markdown(
&session,
&DiffSource::CommitRange(vec!["abc1234567890".to_string()]),
&comment_types(),
true,
&[],
None,
);
assert!(markdown.contains(
"`Review Comment (scope: selected commit range)` - High-level concern across commits"
));
}
#[test]
fn should_include_commit_sha_in_export_for_commit_scoped_comments() {
let mut session = create_test_session();
if let Some(review) = session.get_file_mut(&PathBuf::from("src/main.rs")) {
review.add_line_comment(
10,
Comment::new(
"Wrong variable name".to_string(),
CommentType::from_id("issue"),
Some(LineSide::New),
)
.with_commit_id("abc1234567890"),
);
}
let markdown = generate_markdown(
&session,
&DiffSource::CommitRange(vec!["abc1234567890".to_string()]),
&comment_types(),
true,
&[],
None,
);
assert!(
markdown.contains("`src/main.rs:10` (commit abc1234) - Wrong variable name"),
"commit-scoped comment must include the short SHA in the export; got:\n{markdown}"
);
}
#[test]
fn should_omit_commit_suffix_for_unscoped_comments() {
let session = create_test_session();
let markdown = generate_markdown(
&session,
&DiffSource::WorkingTree,
&comment_types(),
true,
&[],
None,
);
assert!(
!markdown.contains("(commit "),
"unscoped comments must not have a commit suffix; got:\n{markdown}"
);
}
#[test]
fn should_fail_export_when_no_comments() {
let session = ReviewSession::new(
PathBuf::from("/tmp/test-repo"),
"abc1234def".to_string(),
Some("main".to_string()),
SessionDiffSource::WorkingTree,
);
let diff_source = DiffSource::WorkingTree;
let result = export_to_clipboard(&session, &diff_source, &comment_types(), true, &[], None);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), TuicrError::NoComments));
}
#[test]
fn should_generate_export_content_with_comments() {
let session = create_test_session();
let diff_source = DiffSource::WorkingTree;
let result =
generate_export_content(&session, &diff_source, &comment_types(), true, &[], None);
assert!(result.is_ok());
let content = result.unwrap();
assert!(content.contains("I reviewed your code"));
assert!(content.contains("[SUGGESTION]"));
assert!(content.contains("[ISSUE]"));
}
#[test]
fn should_fail_generate_export_content_when_no_comments() {
let session = ReviewSession::new(
PathBuf::from("/tmp/test-repo"),
"abc1234def".to_string(),
Some("main".to_string()),
SessionDiffSource::WorkingTree,
);
let diff_source = DiffSource::WorkingTree;
let result =
generate_export_content(&session, &diff_source, &comment_types(), true, &[], None);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), TuicrError::NoComments));
}
#[test]
fn should_include_commit_range_in_markdown() {
let session = create_test_session();
let diff_source = DiffSource::CommitRange(vec![
"abc1234567890".to_string(),
"def4567890123".to_string(),
]);
let markdown = generate_markdown(&session, &diff_source, &comment_types(), true, &[], None);
assert!(markdown.contains("Reviewing commits: abc1234, def4567"));
}
#[test]
fn should_include_single_commit_in_markdown() {
let session = create_test_session();
let diff_source = DiffSource::CommitRange(vec!["abc1234567890".to_string()]);
let markdown = generate_markdown(&session, &diff_source, &comment_types(), true, &[], None);
assert!(markdown.contains("Reviewing commit: abc1234"));
}
#[test]
fn should_write_osc52_escape_sequence() {
let text = "Hello, World!";
let mut buffer: Vec<u8> = Vec::new();
write_osc52(&mut buffer, text).unwrap();
let output = String::from_utf8(buffer).unwrap();
assert!(output.starts_with("\x1b]52;c;"));
assert!(output.ends_with("\x07"));
let base64_content = &output[7..output.len() - 1];
assert_eq!(BASE64.encode(text), base64_content);
}
#[test]
fn should_encode_empty_string_in_osc52() {
let text = "";
let mut buffer: Vec<u8> = Vec::new();
write_osc52(&mut buffer, text).unwrap();
let output = String::from_utf8(buffer).unwrap();
assert_eq!(output, "\x1b]52;c;\x07");
}
#[test]
fn should_encode_unicode_in_osc52() {
let text = "こんにちは 🦀";
let mut buffer: Vec<u8> = Vec::new();
write_osc52(&mut buffer, text).unwrap();
let output = String::from_utf8(buffer).unwrap();
let base64_content = &output[7..output.len() - 1];
let decoded = String::from_utf8(BASE64.decode(base64_content).unwrap()).unwrap();
assert_eq!(decoded, text);
}
#[test]
fn should_encode_markdown_content_in_osc52() {
let session = create_test_session();
let diff_source = DiffSource::WorkingTree;
let markdown = generate_markdown(&session, &diff_source, &comment_types(), true, &[], None);
let mut buffer: Vec<u8> = Vec::new();
write_osc52(&mut buffer, &markdown).unwrap();
let output = String::from_utf8(buffer).unwrap();
assert!(output.starts_with("\x1b]52;c;"));
assert!(output.ends_with("\x07"));
let base64_content = &output[7..output.len() - 1];
let decoded = String::from_utf8(BASE64.decode(base64_content).unwrap()).unwrap();
assert_eq!(decoded, markdown);
}
#[test]
fn should_export_single_line_range_as_single_line() {
let mut session = ReviewSession::new(
PathBuf::from("/tmp/test-repo"),
"abc1234def".to_string(),
Some("main".to_string()),
SessionDiffSource::WorkingTree,
);
session.add_file(PathBuf::from("src/main.rs"), FileStatus::Modified, 0);
if let Some(review) = session.get_file_mut(&PathBuf::from("src/main.rs")) {
let range = LineRange::single(42);
review.add_line_comment(
42,
Comment::new_with_range(
"Single line comment".to_string(),
CommentType::from_id("note"),
Some(LineSide::New),
range,
),
);
}
let diff_source = DiffSource::WorkingTree;
let markdown = generate_markdown(&session, &diff_source, &comment_types(), true, &[], None);
assert!(markdown.contains("`src/main.rs:42`"));
assert!(!markdown.contains("`src/main.rs:42-42`"));
}
#[test]
fn should_export_line_range_with_start_and_end() {
let mut session = ReviewSession::new(
PathBuf::from("/tmp/test-repo"),
"abc1234def".to_string(),
Some("main".to_string()),
SessionDiffSource::WorkingTree,
);
session.add_file(PathBuf::from("src/main.rs"), FileStatus::Modified, 0);
if let Some(review) = session.get_file_mut(&PathBuf::from("src/main.rs")) {
let range = LineRange::new(10, 15);
review.add_line_comment(
15, Comment::new_with_range(
"Multi-line comment".to_string(),
CommentType::from_id("issue"),
Some(LineSide::New),
range,
),
);
}
let diff_source = DiffSource::WorkingTree;
let markdown = generate_markdown(&session, &diff_source, &comment_types(), true, &[], None);
assert!(markdown.contains("`src/main.rs:10-15`"));
assert!(markdown.contains("Multi-line comment"));
}
#[test]
fn should_export_old_side_line_range_with_tilde() {
let mut session = ReviewSession::new(
PathBuf::from("/tmp/test-repo"),
"abc1234def".to_string(),
Some("main".to_string()),
SessionDiffSource::WorkingTree,
);
session.add_file(PathBuf::from("src/main.rs"), FileStatus::Modified, 0);
if let Some(review) = session.get_file_mut(&PathBuf::from("src/main.rs")) {
let range = LineRange::new(20, 25);
review.add_line_comment(
25, Comment::new_with_range(
"Deleted lines comment".to_string(),
CommentType::from_id("suggestion"),
Some(LineSide::Old),
range,
),
);
}
let diff_source = DiffSource::WorkingTree;
let markdown = generate_markdown(&session, &diff_source, &comment_types(), true, &[], None);
assert!(markdown.contains("`src/main.rs:~20-~25`"));
}
#[test]
fn should_export_single_old_side_line_with_tilde() {
let mut session = ReviewSession::new(
PathBuf::from("/tmp/test-repo"),
"abc1234def".to_string(),
Some("main".to_string()),
SessionDiffSource::WorkingTree,
);
session.add_file(PathBuf::from("src/main.rs"), FileStatus::Modified, 0);
if let Some(review) = session.get_file_mut(&PathBuf::from("src/main.rs")) {
let range = LineRange::single(30);
review.add_line_comment(
30,
Comment::new_with_range(
"Single deleted line".to_string(),
CommentType::from_id("note"),
Some(LineSide::Old),
range,
),
);
}
let diff_source = DiffSource::WorkingTree;
let markdown = generate_markdown(&session, &diff_source, &comment_types(), true, &[], None);
assert!(markdown.contains("`src/main.rs:~30`"));
assert!(!markdown.contains("`src/main.rs:~30-~30`"));
}
#[test]
fn should_handle_comment_without_line_range_field() {
let mut session = ReviewSession::new(
PathBuf::from("/tmp/test-repo"),
"abc1234def".to_string(),
Some("main".to_string()),
SessionDiffSource::WorkingTree,
);
session.add_file(PathBuf::from("src/main.rs"), FileStatus::Modified, 0);
if let Some(review) = session.get_file_mut(&PathBuf::from("src/main.rs")) {
review.add_line_comment(
50,
Comment::new(
"Old style comment".to_string(),
CommentType::from_id("note"),
Some(LineSide::New),
),
);
}
let diff_source = DiffSource::WorkingTree;
let markdown = generate_markdown(&session, &diff_source, &comment_types(), true, &[], None);
assert!(markdown.contains("`src/main.rs:50`"));
}
#[test]
fn should_omit_legend_when_show_legend_is_false() {
let session = create_test_session();
let diff_source = DiffSource::WorkingTree;
let markdown =
generate_markdown(&session, &diff_source, &comment_types(), false, &[], None);
assert!(!markdown.contains("Comment types:"));
assert!(markdown.contains("[SUGGESTION]"));
assert!(markdown.contains("[ISSUE]"));
}
#[test]
fn should_only_list_used_comment_types_in_legend() {
let mut session = ReviewSession::new(
PathBuf::from("/tmp/test-repo"),
"abc1234def".to_string(),
Some("main".to_string()),
SessionDiffSource::WorkingTree,
);
session.add_file(PathBuf::from("src/main.rs"), FileStatus::Modified, 0);
if let Some(review) = session.get_file_mut(&PathBuf::from("src/main.rs")) {
review.add_file_comment(Comment::new(
"Great work!".to_string(),
CommentType::from_id("praise"),
None,
));
}
let markdown = generate_markdown(
&session,
&DiffSource::WorkingTree,
&comment_types(),
true,
&[],
None,
);
assert!(markdown.contains("Comment types: PRAISE (positive feedback)"));
assert!(!markdown.contains("NOTE"));
assert!(!markdown.contains("SUGGESTION"));
assert!(!markdown.contains("ISSUE"));
}
#[test]
fn should_export_none_typed_comments_without_marker_or_legend() {
let mut session = ReviewSession::new(
PathBuf::from("/tmp/test-repo"),
"abc1234def".to_string(),
Some("main".to_string()),
SessionDiffSource::WorkingTree,
);
session.add_file(PathBuf::from("src/main.rs"), FileStatus::Modified, 0);
if let Some(review) = session.get_file_mut(&PathBuf::from("src/main.rs")) {
review.add_line_comment(
42,
Comment::new(
"plain observation".to_string(),
CommentType::None,
Some(LineSide::New),
),
);
}
let none_only = vec![CommentTypeDefinition {
id: CommentType::NONE_ID.to_string(),
label: CommentType::NONE_ID.to_string(),
definition: None,
color: None,
}];
let markdown = generate_markdown(
&session,
&DiffSource::WorkingTree,
&none_only,
true,
&[],
None,
);
assert!(
!markdown.contains("Comment types:"),
"unexpected legend:\n{markdown}"
);
assert!(
!markdown.contains("**["),
"unexpected type marker:\n{markdown}"
);
assert!(
!markdown.contains("[NONE]"),
"None must not render:\n{markdown}"
);
assert!(markdown.contains("`src/main.rs:42`"));
assert!(markdown.contains("plain observation"));
}
fn sample_pr_diff_source() -> DiffSource {
use crate::app::PullRequestDiffSource;
use crate::forge::traits::{ForgeRepository, PrSessionKey};
DiffSource::PullRequest(Box::new(PullRequestDiffSource {
key: PrSessionKey::new(
ForgeRepository::github("github.com", "agavra", "tuicr"),
125,
"abc1234deadbeef".to_string(),
),
base_sha: "1234567890".to_string(),
title: "Support reviews".to_string(),
url: "https://github.com/agavra/tuicr/pull/125".to_string(),
head_ref_name: "reviews".to_string(),
base_ref_name: "main".to_string(),
state: "OPEN".to_string(),
closed: false,
merged: false,
}))
}
fn sample_remote_thread(
id: &str,
author: &str,
body: &str,
line: u32,
resolved: bool,
) -> RemoteReviewThread {
use crate::forge::remote_comments::{RemoteCommentSide, RemoteReviewComment};
RemoteReviewThread {
id: id.to_string(),
path: "src/lib.rs".to_string(),
line: Some(line),
side: RemoteCommentSide::Right,
is_resolved: resolved,
is_outdated: false,
comments: vec![RemoteReviewComment {
id: format!("{id}-root"),
author: Some(author.to_string()),
body: body.to_string(),
created_at: None,
in_reply_to: None,
url: format!("https://github.com/agavra/tuicr/pull/125#discussion_{id}"),
}],
}
}
#[test]
fn should_include_unresolved_remote_threads_grouped_by_file_in_pr_export() {
let mut session = ReviewSession::new(
PathBuf::from("forge:github.com/agavra/tuicr"),
"abc1234deadbeef".to_string(),
Some("reviews".to_string()),
SessionDiffSource::PullRequest,
);
session.add_file(PathBuf::from("src/lib.rs"), FileStatus::Modified, 0);
if let Some(review) = session.get_file_mut(&PathBuf::from("src/lib.rs")) {
review.add_line_comment(
10,
Comment::new(
"Local draft".to_string(),
CommentType::from_id("issue"),
Some(LineSide::New),
),
);
}
let threads = vec![
sample_remote_thread("a", "alice", "Can this be simpler?", 42, false),
sample_remote_thread("b", "bob", "Old resolved", 99, true),
];
let markdown = generate_markdown(
&session,
&sample_pr_diff_source(),
&comment_types(),
true,
&threads,
None,
);
assert!(markdown.contains("Reviewing pull request agavra/tuicr#125"));
assert!(markdown.contains("## Local tuicr Comments"));
assert!(markdown.contains("[ISSUE]"));
assert!(markdown.contains("## Existing GitHub Comments"));
assert!(markdown.contains("### `src/lib.rs`"));
assert!(markdown.contains("@alice - Can this be simpler?"));
assert!(
!markdown.contains("Old resolved"),
"resolved thread leaked into export:\n{markdown}"
);
}
#[test]
fn should_export_pr_remote_threads_even_when_no_local_drafts() {
let session = ReviewSession::new(
PathBuf::from("forge:github.com/agavra/tuicr"),
"abc1234deadbeef".to_string(),
Some("reviews".to_string()),
SessionDiffSource::PullRequest,
);
let threads = vec![sample_remote_thread("a", "alice", "important", 5, false)];
let result = generate_export_content(
&session,
&sample_pr_diff_source(),
&comment_types(),
true,
&threads,
None,
);
let content = result.unwrap();
assert!(content.contains("## Existing GitHub Comments"));
assert!(content.contains("@alice - important"));
}
#[test]
fn should_not_include_remote_comments_outside_pr_mode_in_export() {
let mut session = ReviewSession::new(
PathBuf::from("/tmp"),
"abc".to_string(),
Some("main".to_string()),
SessionDiffSource::WorkingTree,
);
session.add_file(PathBuf::from("src/lib.rs"), FileStatus::Modified, 0);
if let Some(r) = session.get_file_mut(&PathBuf::from("src/lib.rs")) {
r.add_line_comment(
3,
Comment::new(
"Local".to_string(),
CommentType::from_id("note"),
Some(LineSide::New),
),
);
}
let threads = vec![sample_remote_thread("a", "alice", "ignored", 5, false)];
let markdown = generate_markdown(
&session,
&DiffSource::WorkingTree,
&comment_types(),
true,
&threads,
None,
);
assert!(!markdown.contains("Existing GitHub Comments"));
}
#[test]
fn should_only_list_used_custom_types_in_legend() {
let mut session = ReviewSession::new(
PathBuf::from("/tmp/test-repo"),
"abc1234def".to_string(),
Some("main".to_string()),
SessionDiffSource::WorkingTree,
);
session.add_file(PathBuf::from("src/main.rs"), FileStatus::Modified, 0);
if let Some(review) = session.get_file_mut(&PathBuf::from("src/main.rs")) {
review.add_file_comment(Comment::new(
"Needs clarification".to_string(),
CommentType::from_id("note"),
None,
));
}
let custom_types = vec![
CommentTypeDefinition {
id: "note".to_string(),
label: "question".to_string(),
definition: Some("ask for clarification".to_string()),
color: None,
},
CommentTypeDefinition {
id: "issue".to_string(),
label: "issue".to_string(),
definition: Some("problems to fix".to_string()),
color: None,
},
];
let markdown = generate_markdown(
&session,
&DiffSource::WorkingTree,
&custom_types,
true,
&[],
None,
);
assert!(markdown.contains("Comment types: QUESTION (ask for clarification)"));
assert!(!markdown.contains("ISSUE"));
}
}