use similar::{ChangeTag, TextDiff};
use std::fs;
use std::io::Read;
use std::path::Path;
use unicode_width::UnicodeWidthChar;
#[derive(Debug, Clone, PartialEq)]
pub struct DiffLine {
pub tag: ChangeTag,
pub text: String,
}
pub type DiffRow = (Option<DiffLine>, Option<DiffLine>);
pub fn detect_file_line_ending(path: &Path) -> Option<String> {
let mut file = fs::File::open(path).ok()?;
let mut buffer = [0u8; 8192];
let bytes_read = file.read(&mut buffer).ok()?;
if bytes_read == 0 {
return None;
}
let chunk = &buffer[..bytes_read];
let has_lf = chunk.contains(&b'\n');
let has_cr = chunk.contains(&b'\r');
if has_cr && has_lf {
let mut has_crlf = false;
for i in 0..bytes_read.saturating_sub(1) {
if chunk[i] == b'\r' && chunk[i + 1] == b'\n' {
has_crlf = true;
break;
}
}
if has_crlf {
Some("CRLF".to_string())
} else {
Some("LF".to_string())
}
} else if has_lf {
Some("LF".to_string())
} else if has_cr {
Some("CR".to_string())
} else {
None
}
}
fn process_op(
diff: &similar::TextDiff<'_, '_, str>,
op: &similar::DiffOp,
rows: &mut Vec<DiffRow>,
) {
let changes: Vec<_> = diff.iter_changes(op).collect();
let deletes: Vec<_> = changes
.iter()
.filter(|c| c.tag() == ChangeTag::Delete)
.collect();
let inserts: Vec<_> = changes
.iter()
.filter(|c| c.tag() == ChangeTag::Insert)
.collect();
if deletes.is_empty() && inserts.is_empty() {
for change in changes {
let line_content = change.value().to_string();
rows.push((
Some(DiffLine {
tag: ChangeTag::Equal,
text: line_content.clone(),
}),
Some(DiffLine {
tag: ChangeTag::Equal,
text: line_content,
}),
));
}
} else if !deletes.is_empty() && inserts.is_empty() {
for change in deletes {
rows.push((
Some(DiffLine {
tag: ChangeTag::Delete,
text: change.value().to_string(),
}),
None,
));
}
} else if deletes.is_empty() && !inserts.is_empty() {
for change in inserts {
rows.push((
None,
Some(DiffLine {
tag: ChangeTag::Insert,
text: change.value().to_string(),
}),
));
}
} else {
let max_len = std::cmp::max(deletes.len(), inserts.len());
for i in 0..max_len {
let left = if i < deletes.len() {
Some(DiffLine {
tag: ChangeTag::Delete,
text: deletes[i].value().to_string(),
})
} else {
None
};
let right = if i < inserts.len() {
Some(DiffLine {
tag: ChangeTag::Insert,
text: inserts[i].value().to_string(),
})
} else {
None
};
rows.push((left, right));
}
}
}
pub const MAX_DIFF_FILE_BYTES: u64 = 10 * 1024 * 1024;
pub fn load_text_for_diff(path: &Path) -> Result<String, std::io::Error> {
if !path.is_file() {
return Ok(String::new());
}
let meta = fs::metadata(path)?;
if meta.len() > MAX_DIFF_FILE_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"file too large for built-in diff ({} bytes > {} limit): {}",
meta.len(),
MAX_DIFF_FILE_BYTES,
path.display()
),
));
}
let mut file = fs::File::open(path)?;
let mut buf = Vec::with_capacity(meta.len() as usize);
file.read_to_end(&mut buf)?;
let sample_len = buf.len().min(8192);
if buf[..sample_len].contains(&0) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"binary file not supported in built-in diff: {}",
path.display()
),
));
}
let text = String::from_utf8(buf).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"non-UTF-8 file not supported in built-in diff: {}",
path.display()
),
)
})?;
Ok(text.replace("\r\n", "\n"))
}
pub fn compare_files(
left: &Path,
right: &Path,
full_context: bool,
context: usize,
) -> Result<Vec<DiffRow>, std::io::Error> {
let left_text = load_text_for_diff(left)?;
let right_text = load_text_for_diff(right)?;
let diff = TextDiff::from_lines(&left_text, &right_text);
let mut rows = Vec::new();
if full_context {
for op in diff.ops() {
process_op(&diff, op, &mut rows);
}
} else {
for group in diff.grouped_ops(context) {
for op in group {
process_op(&diff, &op, &mut rows);
}
}
}
Ok(rows)
}
pub fn diff_row_is_change(row: &DiffRow) -> bool {
row.0.as_ref().map(|l| l.tag) == Some(ChangeTag::Delete)
|| row.1.as_ref().map(|r| r.tag) == Some(ChangeTag::Insert)
}
pub fn is_replacement_pair(left_line: &Option<DiffLine>, right_line: &Option<DiffLine>) -> bool {
matches!(
(
left_line.as_ref().map(|l| l.tag),
right_line.as_ref().map(|r| r.tag)
),
(Some(ChangeTag::Delete), Some(ChangeTag::Insert))
)
}
pub fn intraline_change_mask(text: &str, other: &str, is_left: bool) -> Vec<bool> {
let diff = TextDiff::from_chars(text, other);
let mut mask = Vec::new();
for change in diff.iter_all_changes() {
match (is_left, change.tag()) {
(true, ChangeTag::Insert) | (false, ChangeTag::Delete) => continue,
(true, ChangeTag::Delete) | (false, ChangeTag::Insert) => {
mask.extend(std::iter::repeat_n(true, change.value().chars().count()));
}
(_, ChangeTag::Equal) => {
mask.extend(std::iter::repeat_n(false, change.value().chars().count()));
}
}
}
let char_count = text.chars().count();
mask.truncate(char_count);
mask.resize(char_count, false);
mask
}
fn wrap_line(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut current = String::new();
let mut current_width = 0;
for ch in text.chars() {
let ch_width = if ch == '\t' {
4
} else {
ch.width().unwrap_or(0)
};
if current_width + ch_width > width && !current.is_empty() {
lines.push(current);
current = String::new();
current_width = 0;
}
current.push(ch);
current_width += ch_width;
}
if !current.is_empty() || lines.is_empty() {
lines.push(current);
}
lines
}
fn logical_row_physical_count(
left_line: &Option<DiffLine>,
right_line: &Option<DiffLine>,
content_width: usize,
wrap: bool,
) -> usize {
if !wrap {
return 1;
}
let left_wrapped = left_line
.as_ref()
.map(|l| wrap_line(l.text.trim_end(), content_width))
.unwrap_or_else(|| vec![String::new()]);
let right_wrapped = right_line
.as_ref()
.map(|r| wrap_line(r.text.trim_end(), content_width))
.unwrap_or_else(|| vec![String::new()]);
std::cmp::max(left_wrapped.len(), right_wrapped.len()).max(1)
}
pub fn diff_row_physical_offsets(
diff_rows: &[DiffRow],
content_width: usize,
wrap: bool,
) -> Vec<usize> {
let mut offsets = Vec::with_capacity(diff_rows.len());
let mut physical = 0usize;
for (left_line, right_line) in diff_rows {
offsets.push(physical);
physical += logical_row_physical_count(left_line, right_line, content_width, wrap);
}
offsets
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HunkCopyDirection {
LeftToRight,
RightToLeft,
}
pub fn diff_hunk_row_ranges(diff_rows: &[DiffRow]) -> Vec<std::ops::Range<usize>> {
let mut hunks = Vec::new();
let mut i = 0;
while i < diff_rows.len() {
if !diff_row_is_change(&diff_rows[i]) {
i += 1;
continue;
}
let start = i;
while i < diff_rows.len() && diff_row_is_change(&diff_rows[i]) {
i += 1;
}
hunks.push(start..i);
}
hunks
}
pub fn diff_row_file_line_indices(diff_rows: &[DiffRow]) -> Vec<(Option<usize>, Option<usize>)> {
let mut left_idx = 0usize;
let mut right_idx = 0usize;
let mut indices = Vec::with_capacity(diff_rows.len());
for (left_line, right_line) in diff_rows {
let left_line_no = left_line.as_ref().map(|_| {
let idx = left_idx;
left_idx += 1;
idx
});
let right_line_no = right_line.as_ref().map(|_| {
let idx = right_idx;
right_idx += 1;
idx
});
indices.push((left_line_no, right_line_no));
}
indices
}
fn hunk_side_line_range(
indices: &[(Option<usize>, Option<usize>)],
row_range: std::ops::Range<usize>,
left_side: bool,
) -> Option<std::ops::Range<usize>> {
let line_nos: Vec<usize> = row_range
.filter_map(|i| {
if left_side {
indices[i].0
} else {
indices[i].1
}
})
.collect();
if line_nos.is_empty() {
None
} else {
Some(*line_nos.first().unwrap()..line_nos.last().unwrap() + 1)
}
}
fn nearest_change_row(diff_rows: &[DiffRow], logical_row: usize) -> Option<usize> {
if logical_row < diff_rows.len() && diff_row_is_change(&diff_rows[logical_row]) {
return Some(logical_row);
}
(logical_row..diff_rows.len())
.find(|&i| diff_row_is_change(&diff_rows[i]))
.or_else(|| {
(0..logical_row)
.rev()
.find(|&i| diff_row_is_change(&diff_rows[i]))
})
}
pub fn hunk_index_at_scroll(
diff_rows: &[DiffRow],
scroll: usize,
content_width: usize,
wrap: bool,
) -> Option<usize> {
if diff_rows.is_empty() {
return None;
}
let offsets = diff_row_physical_offsets(diff_rows, content_width, wrap);
let logical_row = offsets
.iter()
.rposition(|&offset| offset <= scroll)
.unwrap_or(0);
let change_row = nearest_change_row(diff_rows, logical_row)?;
let hunks = diff_hunk_row_ranges(diff_rows);
hunks.iter().position(|range| range.contains(&change_row))
}
fn extract_hunk_lines(
diff_rows: &[DiffRow],
row_range: std::ops::Range<usize>,
from_left: bool,
) -> Vec<String> {
diff_rows[row_range]
.iter()
.filter_map(|(left, right)| {
if from_left {
left.as_ref()
.map(|line| line.text.trim_end_matches(['\r', '\n']).to_string())
} else {
right
.as_ref()
.map(|line| line.text.trim_end_matches(['\r', '\n']).to_string())
}
})
.collect()
}
fn read_file_lines(path: &Path) -> Vec<String> {
fs::read_to_string(path)
.unwrap_or_default()
.replace("\r\n", "\n")
.lines()
.map(str::to_string)
.collect()
}
fn write_file_lines(path: &Path, lines: &[String]) -> Result<(), std::io::Error> {
let ending = detect_file_line_ending(path).unwrap_or_else(|| "LF".to_string());
let sep = match ending.as_str() {
"CRLF" => "\r\n",
"CR" => "\r",
_ => "\n",
};
let mut content = lines.join(sep);
if !lines.is_empty() {
content.push_str(sep);
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, content)
}
fn splice_lines(
mut lines: Vec<String>,
range: std::ops::Range<usize>,
replacement: Vec<String>,
) -> Vec<String> {
let start = range.start.min(lines.len());
let end = range.end.min(lines.len());
lines.splice(start..end, replacement);
lines
}
pub fn apply_hunk_copy(
left_path: &Path,
right_path: &Path,
diff_rows: &[DiffRow],
hunk_index: usize,
direction: HunkCopyDirection,
) -> Result<(), std::io::Error> {
let hunks = diff_hunk_row_ranges(diff_rows);
let row_range = hunks
.get(hunk_index)
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid hunk index"))?
.clone();
let indices = diff_row_file_line_indices(diff_rows);
let left_range = hunk_side_line_range(&indices, row_range.clone(), true);
let right_range = hunk_side_line_range(&indices, row_range.clone(), false);
match direction {
HunkCopyDirection::LeftToRight => {
let source = extract_hunk_lines(diff_rows, row_range, true);
let dest = right_range.unwrap_or_else(|| {
let pos = left_range.as_ref().map(|r| r.start).unwrap_or(0);
pos..pos
});
let mut right_lines = read_file_lines(right_path);
right_lines = splice_lines(right_lines, dest, source);
write_file_lines(right_path, &right_lines)
}
HunkCopyDirection::RightToLeft => {
let source = extract_hunk_lines(diff_rows, row_range, false);
let dest = left_range.unwrap_or_else(|| {
let pos = right_range.as_ref().map(|r| r.start).unwrap_or(0);
pos..pos
});
let mut left_lines = read_file_lines(left_path);
left_lines = splice_lines(left_lines, dest, source);
write_file_lines(left_path, &left_lines)
}
}
}
pub fn jump_to_change_scroll(
diff_rows: &[DiffRow],
current_scroll: usize,
content_width: usize,
wrap: bool,
forward: bool,
) -> Option<usize> {
let offsets = diff_row_physical_offsets(diff_rows, content_width, wrap);
let change_offsets: Vec<usize> = diff_rows
.iter()
.enumerate()
.filter(|(i, row)| diff_row_is_change(row) && offsets.get(*i).is_some())
.map(|(i, _)| offsets[i])
.collect();
if change_offsets.is_empty() {
return None;
}
if forward {
change_offsets
.iter()
.find(|&&offset| offset > current_scroll)
.copied()
.or_else(|| change_offsets.first().copied())
} else {
change_offsets
.iter()
.rfind(|&&offset| offset < current_scroll)
.copied()
.or_else(|| change_offsets.last().copied())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_compare_files_basic() {
let mut left_file = NamedTempFile::new().unwrap();
let mut right_file = NamedTempFile::new().unwrap();
writeln!(left_file, "hello\nworld\nfoo").unwrap();
writeln!(right_file, "hello\nbar\nfoo").unwrap();
let rows = compare_files(left_file.path(), right_file.path(), false, 3).unwrap();
assert!(!rows.is_empty());
let has_aligned_replace = rows.iter().any(|(left, right)| {
left.as_ref()
.is_some_and(|l| l.tag == ChangeTag::Delete && l.text.contains("world"))
&& right
.as_ref()
.is_some_and(|r| r.tag == ChangeTag::Insert && r.text.contains("bar"))
});
assert!(
has_aligned_replace,
"Should contain aligned replace of 'world' with 'bar'"
);
}
#[test]
fn test_compare_files_ignore_crlf() {
let mut left_file = NamedTempFile::new().unwrap();
let mut right_file = NamedTempFile::new().unwrap();
writeln!(left_file, "hello\r\nworld\r\nfoo").unwrap();
writeln!(right_file, "hello\nworld\nfoo").unwrap();
let rows = compare_files(left_file.path(), right_file.path(), false, 3).unwrap();
assert!(
rows.is_empty(),
"Should be empty when files are identical after CRLF normalization"
);
}
#[test]
fn test_compare_files_full_context() {
let mut left_file = NamedTempFile::new().unwrap();
let mut right_file = NamedTempFile::new().unwrap();
writeln!(left_file, "hello\nworld\nfoo").unwrap();
writeln!(right_file, "hello\nbar\nfoo").unwrap();
let rows = compare_files(left_file.path(), right_file.path(), true, 3).unwrap();
assert_eq!(rows.len(), 3);
}
#[test]
fn test_compare_files_context_radius_controls_collapsed_line_count() {
let mut left_file = NamedTempFile::new().unwrap();
let mut right_file = NamedTempFile::new().unwrap();
let mut left_lines: Vec<String> = (1..=10).map(|n| format!("line{n}")).collect();
let mut right_lines = left_lines.clone();
left_lines[5] = "changed-left".to_string();
right_lines[5] = "changed-right".to_string();
writeln!(left_file, "{}", left_lines.join("\n")).unwrap();
writeln!(right_file, "{}", right_lines.join("\n")).unwrap();
let narrow = compare_files(left_file.path(), right_file.path(), false, 1).unwrap();
let wide = compare_files(left_file.path(), right_file.path(), false, 4).unwrap();
assert!(
wide.len() > narrow.len(),
"a wider context radius should include more surrounding lines: narrow={}, wide={}",
narrow.len(),
wide.len()
);
}
#[test]
fn test_load_text_for_diff_missing_is_empty() {
let text = load_text_for_diff(Path::new("/nonexistent/duodiff-missing.txt")).unwrap();
assert!(text.is_empty());
}
#[test]
fn test_load_text_for_diff_rejects_binary() {
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"hello\0world").unwrap();
let err = load_text_for_diff(file.path()).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(err.to_string().contains("binary"));
}
#[test]
fn test_load_text_for_diff_rejects_non_utf8() {
let mut file = NamedTempFile::new().unwrap();
file.write_all(&[0xC3, 0x28]).unwrap(); let err = load_text_for_diff(file.path()).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(err.to_string().contains("non-UTF-8"));
}
#[test]
fn test_load_text_for_diff_rejects_oversize() {
let file = NamedTempFile::new().unwrap();
file.as_file().set_len(MAX_DIFF_FILE_BYTES + 1).unwrap();
let err = load_text_for_diff(file.path()).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(err.to_string().contains("too large"));
}
#[test]
fn test_compare_files_rejects_when_one_side_binary() {
let mut left_file = NamedTempFile::new().unwrap();
let mut right_file = NamedTempFile::new().unwrap();
writeln!(left_file, "plain text").unwrap();
right_file.write_all(b"bin\0ary").unwrap();
let err = compare_files(left_file.path(), right_file.path(), false, 3).unwrap_err();
assert!(err.to_string().contains("binary"));
}
#[test]
fn test_is_replacement_pair() {
let delete = Some(DiffLine {
tag: ChangeTag::Delete,
text: "old".to_string(),
});
let insert = Some(DiffLine {
tag: ChangeTag::Insert,
text: "new".to_string(),
});
let equal = Some(DiffLine {
tag: ChangeTag::Equal,
text: "same".to_string(),
});
assert!(is_replacement_pair(&delete, &insert));
assert!(!is_replacement_pair(&delete, &None));
assert!(!is_replacement_pair(&equal, &insert));
}
#[test]
fn test_intraline_change_mask_highlights_only_changed_chars() {
let left = "let foo = 1;";
let right = "let bar = 1;";
let left_mask = intraline_change_mask(left, right, true);
let right_mask = intraline_change_mask(right, left, false);
assert_eq!(left_mask.len(), left.chars().count());
assert_eq!(right_mask.len(), right.chars().count());
let left_chars: Vec<char> = left.chars().collect();
let highlighted_left: String = left_chars
.iter()
.zip(left_mask.iter())
.filter_map(|(ch, hi)| if *hi { Some(*ch) } else { None })
.collect();
assert_eq!(highlighted_left, "foo");
let right_chars: Vec<char> = right.chars().collect();
let highlighted_right: String = right_chars
.iter()
.zip(right_mask.iter())
.filter_map(|(ch, hi)| if *hi { Some(*ch) } else { None })
.collect();
assert_eq!(highlighted_right, "bar");
}
#[test]
fn test_jump_to_change_scroll_skips_equal_regions() {
let rows = vec![
(
Some(DiffLine {
tag: ChangeTag::Equal,
text: "same".to_string(),
}),
Some(DiffLine {
tag: ChangeTag::Equal,
text: "same".to_string(),
}),
),
(
Some(DiffLine {
tag: ChangeTag::Delete,
text: "old".to_string(),
}),
Some(DiffLine {
tag: ChangeTag::Insert,
text: "new".to_string(),
}),
),
(
Some(DiffLine {
tag: ChangeTag::Equal,
text: "tail".to_string(),
}),
Some(DiffLine {
tag: ChangeTag::Equal,
text: "tail".to_string(),
}),
),
(
Some(DiffLine {
tag: ChangeTag::Delete,
text: "end".to_string(),
}),
None,
),
];
assert_eq!(jump_to_change_scroll(&rows, 0, 40, false, true), Some(1));
assert_eq!(jump_to_change_scroll(&rows, 1, 40, false, true), Some(3));
assert_eq!(jump_to_change_scroll(&rows, 2, 40, false, true), Some(3));
assert_eq!(jump_to_change_scroll(&rows, 3, 40, false, true), Some(1));
assert_eq!(jump_to_change_scroll(&rows, 3, 40, false, false), Some(1));
assert_eq!(jump_to_change_scroll(&rows, 2, 40, false, false), Some(1));
assert_eq!(jump_to_change_scroll(&rows, 1, 40, false, false), Some(3));
assert_eq!(jump_to_change_scroll(&rows, 0, 40, false, false), Some(3));
}
#[test]
fn test_jump_to_change_scroll_respects_wrap_physical_rows() {
let long = "a".repeat(20);
let rows = vec![
(
Some(DiffLine {
tag: ChangeTag::Equal,
text: "ctx".to_string(),
}),
Some(DiffLine {
tag: ChangeTag::Equal,
text: "ctx".to_string(),
}),
),
(
Some(DiffLine {
tag: ChangeTag::Delete,
text: long.clone(),
}),
Some(DiffLine {
tag: ChangeTag::Insert,
text: long,
}),
),
];
assert_eq!(jump_to_change_scroll(&rows, 0, 8, true, true), Some(1));
assert_eq!(jump_to_change_scroll(&rows, 2, 8, true, false), Some(1));
}
#[test]
fn test_diff_hunk_row_ranges_groups_contiguous_changes() {
let rows = vec![
(
Some(DiffLine {
tag: ChangeTag::Equal,
text: "ctx".to_string(),
}),
Some(DiffLine {
tag: ChangeTag::Equal,
text: "ctx".to_string(),
}),
),
(
Some(DiffLine {
tag: ChangeTag::Delete,
text: "old".to_string(),
}),
Some(DiffLine {
tag: ChangeTag::Insert,
text: "new".to_string(),
}),
),
(
Some(DiffLine {
tag: ChangeTag::Equal,
text: "mid".to_string(),
}),
Some(DiffLine {
tag: ChangeTag::Equal,
text: "mid".to_string(),
}),
),
(
None,
Some(DiffLine {
tag: ChangeTag::Insert,
text: "added".to_string(),
}),
),
];
assert_eq!(diff_hunk_row_ranges(&rows), vec![1..2, 3..4]);
}
#[test]
fn test_apply_hunk_copy_left_to_right_replaces_one_block() {
let mut left_file = NamedTempFile::new().unwrap();
let mut right_file = NamedTempFile::new().unwrap();
writeln!(left_file, "alpha").unwrap();
writeln!(left_file, "left-only").unwrap();
writeln!(left_file, "gamma").unwrap();
writeln!(right_file, "alpha").unwrap();
writeln!(right_file, "right-only").unwrap();
writeln!(right_file, "gamma").unwrap();
let rows = compare_files(left_file.path(), right_file.path(), true, 3).unwrap();
assert_eq!(diff_hunk_row_ranges(&rows).len(), 1);
apply_hunk_copy(
left_file.path(),
right_file.path(),
&rows,
0,
HunkCopyDirection::LeftToRight,
)
.unwrap();
let updated = fs::read_to_string(right_file.path()).unwrap();
assert!(updated.contains("left-only"));
assert!(!updated.contains("right-only"));
}
#[test]
fn test_apply_hunk_copy_right_to_left_inserts_missing_block() {
let mut left_file = NamedTempFile::new().unwrap();
let mut right_file = NamedTempFile::new().unwrap();
writeln!(left_file, "keep").unwrap();
writeln!(right_file, "keep").unwrap();
writeln!(right_file, "from-right").unwrap();
let rows = compare_files(left_file.path(), right_file.path(), true, 3).unwrap();
apply_hunk_copy(
left_file.path(),
right_file.path(),
&rows,
0,
HunkCopyDirection::RightToLeft,
)
.unwrap();
let updated = fs::read_to_string(left_file.path()).unwrap();
assert!(updated.contains("from-right"));
}
#[test]
fn test_hunk_index_at_scroll_finds_nearest_change() {
let rows = vec![
(
Some(DiffLine {
tag: ChangeTag::Equal,
text: "ctx".to_string(),
}),
Some(DiffLine {
tag: ChangeTag::Equal,
text: "ctx".to_string(),
}),
),
(
Some(DiffLine {
tag: ChangeTag::Delete,
text: "old".to_string(),
}),
Some(DiffLine {
tag: ChangeTag::Insert,
text: "new".to_string(),
}),
),
];
assert_eq!(hunk_index_at_scroll(&rows, 0, 40, false), Some(0));
}
#[test]
fn test_detect_file_line_ending() {
let mut lf_file = NamedTempFile::new().unwrap();
let mut crlf_file = NamedTempFile::new().unwrap();
write!(lf_file, "hello\nworld").unwrap();
write!(crlf_file, "hello\r\nworld").unwrap();
assert_eq!(
detect_file_line_ending(lf_file.path()),
Some("LF".to_string())
);
assert_eq!(
detect_file_line_ending(crlf_file.path()),
Some("CRLF".to_string())
);
}
}