use std::collections::HashMap;
use tracing::warn;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineType {
Added,
Removed,
Context,
Header,
Meta,
}
impl LineType {
#[inline]
pub fn marker(&self) -> Option<&'static str> {
match self {
Self::Added => Some("+"),
Self::Removed => Some("-"),
Self::Context => Some(" "),
Self::Header | Self::Meta => None,
}
}
#[inline]
pub fn fg_color(&self) -> Option<ratatui::style::Color> {
match self {
Self::Header => Some(ratatui::style::Color::Cyan),
Self::Meta => Some(ratatui::style::Color::Yellow),
Self::Added => Some(ratatui::style::Color::Green),
Self::Removed => Some(ratatui::style::Color::Red),
Self::Context => None,
}
}
#[inline]
pub fn bg_color(&self) -> Option<ratatui::style::Color> {
match self {
Self::Added => Some(ratatui::style::Color::Rgb(0, 60, 0)),
Self::Removed => Some(ratatui::style::Color::Rgb(60, 0, 0)),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct DiffLineInfo {
pub line_content: String,
pub line_type: LineType,
pub new_line_number: Option<u32>,
pub diff_position: Option<u32>,
}
pub struct PatchIndex<'a> {
lines: Vec<PatchLineInfo<'a>>,
}
#[derive(Debug, Clone)]
pub struct PatchLineInfo<'a> {
pub content: &'a str,
pub line_type: LineType,
pub new_line_number: Option<u32>,
pub diff_position: Option<u32>,
}
impl<'a> PatchIndex<'a> {
pub fn build(patch: &'a str) -> Self {
if patch.is_empty() {
return Self { lines: Vec::new() };
}
let line_iter: Vec<&'a str> = patch.lines().collect();
let mut lines = Vec::with_capacity(line_iter.len());
let mut new_line_number: Option<u32> = None;
let mut position_counter: Option<u32> = None;
for line in &line_iter {
let line_clean = line.strip_suffix('\r').unwrap_or(line);
let (line_type, content) = classify_line(line_clean);
match line_type {
LineType::Meta => {}
LineType::Header => {
new_line_number = parse_hunk_header(line_clean);
position_counter = Some(position_counter.map_or(0, |p| p + 1));
}
LineType::Added | LineType::Context => {
position_counter = position_counter.map(|p| p + 1);
}
LineType::Removed => {
position_counter = position_counter.map(|p| p + 1);
}
}
let current_new_line = match line_type {
LineType::Removed | LineType::Header | LineType::Meta => None,
_ => new_line_number,
};
let current_position = match line_type {
LineType::Meta => None,
LineType::Header if position_counter == Some(0) => None,
_ => position_counter,
};
lines.push(PatchLineInfo {
content,
line_type,
new_line_number: current_new_line,
diff_position: current_position,
});
match line_type {
LineType::Added | LineType::Context => {
if let Some(n) = new_line_number {
new_line_number = Some(n + 1);
}
}
_ => {}
}
}
Self { lines }
}
pub fn get(&self, line_index: usize) -> Option<&PatchLineInfo<'a>> {
self.lines.get(line_index)
}
pub fn len(&self) -> usize {
self.lines.len()
}
pub fn is_empty(&self) -> bool {
self.lines.is_empty()
}
}
impl PatchLineInfo<'_> {
pub fn to_diff_line_info(&self) -> DiffLineInfo {
DiffLineInfo {
line_content: self.content.to_string(),
line_type: self.line_type,
new_line_number: self.new_line_number,
diff_position: self.diff_position,
}
}
}
fn parse_hunk_header(line: &str) -> Option<u32> {
let plus_pos = line.find('+')?;
let after_plus = &line[plus_pos + 1..];
let end_pos = after_plus.find([',', ' ']).unwrap_or(after_plus.len());
let num_str = &after_plus[..end_pos];
num_str.parse().ok()
}
pub fn get_line_info(patch: &str, line_index: usize) -> Option<DiffLineInfo> {
let lines: Vec<&str> = patch.lines().collect();
if line_index >= lines.len() {
return None;
}
let mut new_line_number: Option<u32> = None;
let mut position_counter: Option<u32> = None;
for (i, line) in lines.iter().enumerate() {
let (line_type, content) = classify_line(line);
match line_type {
LineType::Meta => {
}
LineType::Header => {
new_line_number = parse_hunk_header(line);
position_counter = Some(position_counter.map_or(0, |p| p + 1));
}
LineType::Added | LineType::Context => {
position_counter = position_counter.map(|p| p + 1);
}
LineType::Removed => {
position_counter = position_counter.map(|p| p + 1);
}
}
if i == line_index {
let current_new_line = match line_type {
LineType::Removed | LineType::Header | LineType::Meta => None,
_ => new_line_number,
};
let current_position = match line_type {
LineType::Meta => None,
LineType::Header if position_counter == Some(0) => None,
_ => position_counter,
};
return Some(DiffLineInfo {
line_content: content.to_string(),
line_type,
new_line_number: current_new_line,
diff_position: current_position,
});
}
match line_type {
LineType::Added | LineType::Context => {
if let Some(n) = new_line_number {
new_line_number = Some(n + 1);
}
}
_ => {}
}
}
None
}
pub fn classify_line(line: &str) -> (LineType, &str) {
if line.starts_with("@@") {
(LineType::Header, line)
} else if line.starts_with("+++")
|| line.starts_with("---")
|| line.starts_with("diff ")
|| line.starts_with("index ")
{
(LineType::Meta, line)
} else if let Some(content) = line.strip_prefix('+') {
(LineType::Added, content)
} else if let Some(content) = line.strip_prefix('-') {
(LineType::Removed, content)
} else if let Some(content) = line.strip_prefix(' ') {
(LineType::Context, content)
} else {
(LineType::Context, line)
}
}
pub fn validate_multiline_range(patch: &str, start: usize, end: usize) -> bool {
let lines: Vec<&str> = patch.lines().collect();
for idx in start..=end {
let Some(line) = lines.get(idx) else {
return false;
};
let (line_type, _) = classify_line(line);
match line_type {
LineType::Added | LineType::Context => {}
_ => return false,
}
}
true
}
pub fn line_number_to_position(patch: &str, target_line: u32) -> Option<u32> {
let mut new_line_number: Option<u32> = None;
let mut position_counter: Option<u32> = None;
for line in patch.lines() {
let (line_type, _) = classify_line(line);
match line_type {
LineType::Meta => continue,
LineType::Header => {
new_line_number = parse_hunk_header(line);
position_counter = Some(position_counter.map_or(0, |p| p + 1));
}
LineType::Added | LineType::Context => {
position_counter = position_counter.map(|p| p + 1);
if new_line_number == Some(target_line) {
return position_counter;
}
new_line_number = new_line_number.map(|n| n + 1);
}
LineType::Removed => {
position_counter = position_counter.map(|p| p + 1);
}
}
}
None
}
pub fn parse_unified_diff(unified_diff: &str) -> HashMap<String, String> {
let mut result = HashMap::new();
if unified_diff.is_empty() {
return result;
}
let mut current_filename: Option<String> = None;
let mut current_patch_start: Option<usize> = None;
let mut pending_minus_filename: Option<String> = None;
let mut byte_offset: usize = 0;
for raw_line in unified_diff.split('\n') {
let line_start = byte_offset;
byte_offset += raw_line.len() + 1;
let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
if line.starts_with("diff --git ") {
if let (Some(filename), Some(start)) = (¤t_filename, current_patch_start) {
let end = trim_trailing_newline(unified_diff, line_start);
if end > start {
let patch = normalize_line_endings(&unified_diff[start..end]);
result.insert(filename.clone(), patch);
}
}
current_filename = extract_filename(line);
current_patch_start = Some(line_start);
pending_minus_filename = None;
} else if current_filename.is_none() && current_patch_start.is_some() {
if let Some(rest) = line.strip_prefix("+++ ") {
if rest != "/dev/null" {
current_filename = strip_diff_prefix(rest);
} else if let Some(ref pending) = pending_minus_filename {
current_filename = Some(pending.clone());
pending_minus_filename = None;
}
} else if let Some(rest) = line.strip_prefix("--- ") {
if rest != "/dev/null" {
pending_minus_filename = strip_diff_prefix(rest);
}
}
}
}
if let (Some(filename), Some(start)) = (current_filename, current_patch_start) {
let end = trim_trailing_newline(unified_diff, unified_diff.len());
if end > start {
let patch = normalize_line_endings(&unified_diff[start..end]);
result.insert(filename, patch);
}
}
result
}
fn trim_trailing_newline(s: &str, pos: usize) -> usize {
let mut end = pos;
if end > 0 && s.as_bytes()[end - 1] == b'\n' {
end -= 1;
}
if end > 0 && s.as_bytes()[end - 1] == b'\r' {
end -= 1;
}
end
}
fn normalize_line_endings(s: &str) -> String {
if s.contains('\r') {
s.replace("\r\n", "\n")
} else {
s.to_string()
}
}
fn strip_diff_prefix(path: &str) -> Option<String> {
if path.len() >= 2 && path.as_bytes()[1] == b'/' {
Some(path[2..].to_string())
} else {
Some(path.to_string())
}
}
fn extract_filename(git_diff_line: &str) -> Option<String> {
let content = git_diff_line.strip_prefix("diff --git ")?;
if content.len() < 2 || content.as_bytes()[1] != b'/' {
warn!("Failed to parse git diff line: {}", git_diff_line);
return None;
}
let first_prefix = content.as_bytes()[0];
let first_path = &content[2..];
let total_len = first_path.len();
if total_len >= 3 && (total_len - 3) % 2 == 0 {
let path_len = (total_len - 3) / 2;
if path_len > 0 {
let bytes = first_path.as_bytes();
let sep = path_len;
if bytes[sep] == b' ' && bytes[sep + 2] == b'/' {
let path1 = &first_path[..path_len];
let path2 = &first_path[sep + 3..];
if path1 == path2 {
return Some(path2.to_string());
}
}
}
}
let second_prefix = match first_prefix {
b'a' => b'b',
b'c' | b'i' | b'o' => b'w',
_ => {
warn!(
"Failed to parse git diff line (unknown prefix): {}",
git_diff_line
);
return None;
}
};
let bytes = first_path.as_bytes();
let mut matches: Vec<usize> = Vec::new();
for i in 0..bytes.len().saturating_sub(2) {
if bytes[i] == b' ' && bytes[i + 1] == second_prefix && bytes[i + 2] == b'/' {
matches.push(i);
}
}
if matches.len() == 1 {
let path2 = &first_path[matches[0] + 3..];
if !path2.is_empty() {
return Some(path2.to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
use std::collections::BTreeMap;
fn format_parsed_diff(result: &HashMap<String, String>) -> String {
let sorted: BTreeMap<&str, &str> = result
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let mut output = String::new();
for (i, (filename, patch)) in sorted.iter().enumerate() {
if i > 0 {
output.push_str("\n---\n");
}
output.push_str(&format!("[{}]\n{}", filename, patch));
}
output
}
const SAMPLE_PATCH: &str = r#"@@ -1,4 +1,5 @@
line 1
-old line 2
+new line 2
+added line
line 3"#;
const UNIFIED_DIFF_SINGLE: &str = r#"diff --git a/src/main.rs b/src/main.rs
index 1234567..abcdefg 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,4 @@
fn main() {
+ println!("Hello");
}
"#;
const UNIFIED_DIFF_MULTIPLE: &str = r#"diff --git a/src/lib.rs b/src/lib.rs
index 1111111..2222222 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,2 +1,3 @@
pub mod app;
+pub mod config;
diff --git a/src/app.rs b/src/app.rs
index 3333333..4444444 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -10,6 +10,7 @@
struct App {
name: String,
+ version: String,
}
"#;
const UNIFIED_DIFF_NEW_FILE: &str = r#"diff --git a/src/new_file.rs b/src/new_file.rs
new file mode 100644
index 0000000..1234567
--- /dev/null
+++ b/src/new_file.rs
@@ -0,0 +1,3 @@
+fn new_function() {
+ todo!()
+}
"#;
const UNIFIED_DIFF_DELETED: &str = r#"diff --git a/src/old_file.rs b/src/old_file.rs
deleted file mode 100644
index 1234567..0000000
--- a/src/old_file.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-fn old_function() {
- todo!()
-}
"#;
const UNIFIED_DIFF_RENAMED: &str = r#"diff --git a/src/old_name.rs b/src/new_name.rs
similarity index 95%
rename from src/old_name.rs
rename to src/new_name.rs
index 1234567..abcdefg 100644
--- a/src/old_name.rs
+++ b/src/new_name.rs
@@ -1,3 +1,3 @@
-fn old_name() {
+fn new_name() {
}
"#;
const UNIFIED_DIFF_BINARY: &str = r#"diff --git a/image.png b/image.png
new file mode 100644
index 0000000..1234567
Binary files /dev/null and b/image.png differ
"#;
#[test]
fn test_parse_hunk_header() {
assert_eq!(parse_hunk_header("@@ -1,4 +1,5 @@"), Some(1));
assert_eq!(parse_hunk_header("@@ -10,3 +15,7 @@"), Some(15));
assert_eq!(parse_hunk_header("@@ -1 +1 @@"), Some(1));
}
#[test]
fn test_get_line_info_header() {
let info = get_line_info(SAMPLE_PATCH, 0).unwrap();
assert_eq!(info.line_type, LineType::Header);
assert!(info.new_line_number.is_none());
}
#[test]
fn test_get_line_info_context() {
let info = get_line_info(SAMPLE_PATCH, 1).unwrap();
assert_eq!(info.line_type, LineType::Context);
assert_eq!(info.line_content, "line 1");
assert_eq!(info.new_line_number, Some(1));
}
#[test]
fn test_get_line_info_removed() {
let info = get_line_info(SAMPLE_PATCH, 2).unwrap();
assert_eq!(info.line_type, LineType::Removed);
assert_eq!(info.line_content, "old line 2");
assert!(info.new_line_number.is_none());
}
#[test]
fn test_get_line_info_added() {
let info = get_line_info(SAMPLE_PATCH, 3).unwrap();
assert_eq!(info.line_type, LineType::Added);
assert_eq!(info.line_content, "new line 2");
assert_eq!(info.new_line_number, Some(2));
}
#[test]
fn test_classify_line_no_prefix() {
let (line_type, content) = classify_line("no prefix");
assert_eq!(line_type, LineType::Context);
assert_eq!(content, "no prefix");
}
#[test]
fn test_classify_line_empty() {
let (line_type, content) = classify_line("");
assert_eq!(line_type, LineType::Context);
assert_eq!(content, "");
}
#[test]
fn test_parse_hunk_header_no_comma_no_space() {
let patch = "@@ -1 +42\ntest";
let info = get_line_info(patch, 1).unwrap();
assert_eq!(info.line_type, LineType::Context);
assert_eq!(info.new_line_number, Some(42));
}
#[test]
fn test_out_of_bounds() {
assert!(get_line_info(SAMPLE_PATCH, 100).is_none());
}
#[test]
fn test_extract_filename() {
assert_eq!(
extract_filename("diff --git a/src/foo.rs b/src/foo.rs"),
Some("src/foo.rs".to_string())
);
assert_eq!(
extract_filename("diff --git a/main.rs b/main.rs"),
Some("main.rs".to_string())
);
assert_eq!(
extract_filename("diff --git a/deep/nested/path/file.rs b/deep/nested/path/file.rs"),
Some("deep/nested/path/file.rs".to_string())
);
}
#[test]
fn test_extract_filename_renamed() {
assert_eq!(
extract_filename("diff --git a/src/old_name.rs b/src/new_name.rs"),
Some("src/new_name.rs".to_string())
);
}
#[test]
fn test_extract_filename_mnemonic_prefix() {
assert_eq!(
extract_filename("diff --git c/src/foo.rs w/src/foo.rs"),
Some("src/foo.rs".to_string())
);
assert_eq!(
extract_filename("diff --git i/src/bar.rs w/src/bar.rs"),
Some("src/bar.rs".to_string())
);
assert_eq!(
extract_filename("diff --git c/src/old.rs w/src/new.rs"),
Some("src/new.rs".to_string())
);
}
#[test]
fn test_extract_filename_invalid() {
assert_eq!(extract_filename("not a diff line"), None);
assert_eq!(extract_filename("diff something else"), None);
}
#[test]
fn test_extract_filename_no_separator() {
assert_eq!(extract_filename("diff --git a/file nob"), None);
}
#[test]
fn test_extract_filename_spaces_with_subdir() {
assert_eq!(
extract_filename("diff --git a/my Folder/src/file.rs b/my Folder/src/file.rs"),
Some("my Folder/src/file.rs".to_string())
);
assert_eq!(
extract_filename("diff --git a/a b/c d/file.rs b/a b/c d/file.rs"),
Some("a b/c d/file.rs".to_string())
);
assert_eq!(
extract_filename(
"diff --git a/docs/my project/sub b/notes.md b/docs/my project/sub b/notes.md"
),
Some("docs/my project/sub b/notes.md".to_string())
);
}
#[test]
fn test_extract_filename_ambiguous_falls_back_to_none() {
assert_eq!(
extract_filename("diff --git a/x b/old.rs b/x b/new.rs"),
None
);
}
#[test]
fn test_parse_unified_diff_plusplus_fallback() {
let diff = "\
diff --git a/x b/old.rs b/x b/new.rs
index 1234567..abcdefg 100644
--- a/x b/old.rs
+++ b/x b/new.rs
@@ -1,3 +1,3 @@
line1
-old
+new";
let result = parse_unified_diff(diff);
assert!(
result.contains_key("x b/new.rs"),
"expected key 'x b/new.rs', got: {:?}",
result.keys().collect::<Vec<_>>()
);
}
#[test]
fn test_strip_diff_prefix() {
assert_eq!(
strip_diff_prefix("b/src/file.rs"),
Some("src/file.rs".to_string())
);
assert_eq!(strip_diff_prefix("w/file.rs"), Some("file.rs".to_string()));
assert_eq!(strip_diff_prefix("file.rs"), Some("file.rs".to_string()));
}
#[test]
fn test_parse_single_file() {
let result = parse_unified_diff(UNIFIED_DIFF_SINGLE);
assert_snapshot!(format_parsed_diff(&result), @r#"
[src/main.rs]
diff --git a/src/main.rs b/src/main.rs
index 1234567..abcdefg 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,4 @@
fn main() {
+ println!("Hello");
}
"#);
}
#[test]
fn test_parse_multiple_files() {
let result = parse_unified_diff(UNIFIED_DIFF_MULTIPLE);
assert_snapshot!(format_parsed_diff(&result), @r#"
[src/app.rs]
diff --git a/src/app.rs b/src/app.rs
index 3333333..4444444 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -10,6 +10,7 @@
struct App {
name: String,
+ version: String,
}
---
[src/lib.rs]
diff --git a/src/lib.rs b/src/lib.rs
index 1111111..2222222 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,2 +1,3 @@
pub mod app;
+pub mod config;
"#);
}
#[test]
fn test_parse_new_file() {
let result = parse_unified_diff(UNIFIED_DIFF_NEW_FILE);
assert_snapshot!(format_parsed_diff(&result), @r#"
[src/new_file.rs]
diff --git a/src/new_file.rs b/src/new_file.rs
new file mode 100644
index 0000000..1234567
--- /dev/null
+++ b/src/new_file.rs
@@ -0,0 +1,3 @@
+fn new_function() {
+ todo!()
+}
"#);
}
#[test]
fn test_parse_deleted_file() {
let result = parse_unified_diff(UNIFIED_DIFF_DELETED);
assert_snapshot!(format_parsed_diff(&result), @r#"
[src/old_file.rs]
diff --git a/src/old_file.rs b/src/old_file.rs
deleted file mode 100644
index 1234567..0000000
--- a/src/old_file.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-fn old_function() {
- todo!()
-}
"#);
}
#[test]
fn test_parse_renamed_file() {
let result = parse_unified_diff(UNIFIED_DIFF_RENAMED);
assert_snapshot!(format_parsed_diff(&result), @r#"
[src/new_name.rs]
diff --git a/src/old_name.rs b/src/new_name.rs
similarity index 95%
rename from src/old_name.rs
rename to src/new_name.rs
index 1234567..abcdefg 100644
--- a/src/old_name.rs
+++ b/src/new_name.rs
@@ -1,3 +1,3 @@
-fn old_name() {
+fn new_name() {
}
"#);
}
#[test]
fn test_parse_binary_file() {
let result = parse_unified_diff(UNIFIED_DIFF_BINARY);
assert_snapshot!(format_parsed_diff(&result), @r#"
[image.png]
diff --git a/image.png b/image.png
new file mode 100644
index 0000000..1234567
Binary files /dev/null and b/image.png differ
"#);
}
#[test]
fn test_parse_empty_diff() {
let result = parse_unified_diff("");
assert!(result.is_empty());
}
#[test]
fn test_filename_matches_github_api_format() {
let result = parse_unified_diff(UNIFIED_DIFF_SINGLE);
let filename = result.keys().next().unwrap();
assert!(!filename.starts_with("a/"));
assert!(!filename.starts_with("b/"));
assert_eq!(filename, "src/main.rs");
}
#[test]
fn test_diff_position_single_hunk() {
let info = get_line_info(SAMPLE_PATCH, 0).unwrap();
assert_eq!(info.diff_position, None);
let info = get_line_info(SAMPLE_PATCH, 1).unwrap();
assert_eq!(info.diff_position, Some(1));
let info = get_line_info(SAMPLE_PATCH, 2).unwrap();
assert_eq!(info.diff_position, Some(2));
let info = get_line_info(SAMPLE_PATCH, 3).unwrap();
assert_eq!(info.diff_position, Some(3));
let info = get_line_info(SAMPLE_PATCH, 4).unwrap();
assert_eq!(info.diff_position, Some(4));
let info = get_line_info(SAMPLE_PATCH, 5).unwrap();
assert_eq!(info.diff_position, Some(5));
}
#[test]
fn test_diff_position_with_meta_lines() {
let patch = "diff --git a/foo.rs b/foo.rs\nindex 123..456 100644\n--- a/foo.rs\n+++ b/foo.rs\n@@ -1,2 +1,3 @@\n fn main() {\n+ println!(\"hello\");\n }";
let info = get_line_info(patch, 0).unwrap();
assert_eq!(info.line_type, LineType::Meta);
assert_eq!(info.diff_position, None);
let info = get_line_info(patch, 3).unwrap();
assert_eq!(info.line_type, LineType::Meta);
assert_eq!(info.diff_position, None);
let info = get_line_info(patch, 4).unwrap();
assert_eq!(info.line_type, LineType::Header);
assert_eq!(info.diff_position, None);
let info = get_line_info(patch, 5).unwrap();
assert_eq!(info.line_type, LineType::Context);
assert_eq!(info.diff_position, Some(1));
let info = get_line_info(patch, 6).unwrap();
assert_eq!(info.line_type, LineType::Added);
assert_eq!(info.diff_position, Some(2));
}
#[test]
fn test_diff_position_no_meta_lines() {
let patch = "@@ -1,2 +1,3 @@\n fn main() {\n+ println!(\"hello\");\n }";
let info = get_line_info(patch, 0).unwrap();
assert_eq!(info.diff_position, None);
let info = get_line_info(patch, 1).unwrap();
assert_eq!(info.diff_position, Some(1));
}
#[test]
fn test_diff_position_multi_hunk() {
let patch = "@@ -1,3 +1,3 @@\n-old1\n+new1\n ctx\n@@ -10,3 +10,3 @@\n-old2\n+new2\n ctx2";
let info = get_line_info(patch, 0).unwrap();
assert_eq!(info.diff_position, None);
let info = get_line_info(patch, 4).unwrap();
assert_eq!(info.line_type, LineType::Header);
assert_eq!(info.diff_position, Some(4));
let info = get_line_info(patch, 6).unwrap();
assert_eq!(info.diff_position, Some(6));
let info = get_line_info(patch, 7).unwrap();
assert_eq!(info.diff_position, Some(7));
}
#[test]
fn test_line_number_to_position_basic() {
assert_eq!(line_number_to_position(SAMPLE_PATCH, 1), Some(1));
assert_eq!(line_number_to_position(SAMPLE_PATCH, 2), Some(3));
assert_eq!(line_number_to_position(SAMPLE_PATCH, 3), Some(4));
assert_eq!(line_number_to_position(SAMPLE_PATCH, 4), Some(5));
}
#[test]
fn test_line_number_to_position_multi_hunk() {
let patch = "@@ -1,3 +1,3 @@\n-old1\n+new1\n ctx\n@@ -10,2 +10,2 @@\n-old2\n+new2";
assert_eq!(line_number_to_position(patch, 1), Some(2));
assert_eq!(line_number_to_position(patch, 2), Some(3));
assert_eq!(line_number_to_position(patch, 10), Some(6));
}
#[test]
fn test_line_number_to_position_with_meta_lines() {
let patch = "diff --git a/foo.rs b/foo.rs\nindex 123..456 100644\n--- a/foo.rs\n+++ b/foo.rs\n@@ -1,2 +1,3 @@\n fn main() {\n+ println!(\"hello\");\n }";
assert_eq!(line_number_to_position(patch, 1), Some(1));
assert_eq!(line_number_to_position(patch, 2), Some(2));
assert_eq!(line_number_to_position(patch, 3), Some(3));
}
#[test]
fn test_line_number_to_position_nonexistent_line() {
assert_eq!(line_number_to_position(SAMPLE_PATCH, 999), None);
assert_eq!(line_number_to_position(SAMPLE_PATCH, 0), None);
}
#[test]
fn test_validate_multiline_range_valid_single_hunk() {
let patch = "@@ -1,3 +1,4 @@\n context line\n+added line\n another context\n-removed line";
assert!(validate_multiline_range(patch, 1, 2));
assert!(validate_multiline_range(patch, 1, 1));
}
#[test]
fn test_validate_multiline_range_includes_removed_line() {
let patch = "@@ -1,3 +1,4 @@\n context line\n+added line\n another context\n-removed line";
assert!(!validate_multiline_range(patch, 1, 4));
}
#[test]
fn test_validate_multiline_range_crosses_hunk_boundary() {
let patch = "@@ -1,2 +1,2 @@\n line1\n+new line2\n@@ -10,2 +10,2 @@\n line10\n+new line11";
assert!(!validate_multiline_range(patch, 1, 4));
assert!(validate_multiline_range(patch, 1, 2));
assert!(validate_multiline_range(patch, 4, 5));
}
#[test]
fn test_validate_multiline_range_starts_at_header() {
let patch = "@@ -1,2 +1,2 @@\n line1\n+added";
assert!(!validate_multiline_range(patch, 0, 1));
}
#[test]
fn test_validate_multiline_range_out_of_bounds() {
let patch = "@@ -1,2 +1,2 @@\n line1";
assert!(!validate_multiline_range(patch, 1, 10));
}
#[test]
fn test_validate_multiline_range_removed_lines_in_middle() {
let patch = "@@ -1,5 +1,4 @@\n context1\n+added1\n-removed_mid\n context2\n+added2";
assert!(!validate_multiline_range(patch, 1, 4));
assert!(validate_multiline_range(patch, 1, 2));
assert!(validate_multiline_range(patch, 4, 5));
}
#[test]
fn test_validate_multiline_range_all_removed() {
let patch = "@@ -1,3 +0,0 @@\n-removed1\n-removed2\n-removed3";
assert!(!validate_multiline_range(patch, 1, 3));
}
#[test]
fn test_multiline_range_new_side_lines_contiguous() {
let patch = "@@ -1,4 +1,5 @@\n context1\n+added1\n+added2\n context2\n+added3";
assert!(validate_multiline_range(patch, 1, 4));
let start_info = get_line_info(patch, 1).unwrap();
let end_info = get_line_info(patch, 4).unwrap();
assert_eq!(start_info.new_line_number, Some(1));
assert_eq!(end_info.new_line_number, Some(4));
for idx in 1..=4 {
let info = get_line_info(patch, idx).unwrap();
assert!(info.new_line_number.is_some());
}
}
#[test]
fn test_single_line_vs_multiline_dispatch() {
let patch = "@@ -1,3 +1,4 @@\n context1\n+added1\n+added2\n context2";
let info = get_line_info(patch, 2).unwrap();
assert_eq!(info.new_line_number, Some(2));
let start_line = if info.new_line_number == info.new_line_number {
None
} else {
info.new_line_number
};
assert_eq!(start_line, None);
let start_info = get_line_info(patch, 1).unwrap();
let end_info = get_line_info(patch, 3).unwrap();
let start_ln = start_info.new_line_number.unwrap();
let end_ln = end_info.new_line_number.unwrap();
let start_line = if start_ln < end_ln {
Some(start_ln)
} else {
None
};
assert_eq!(start_line, Some(1));
assert_eq!(end_ln, 3);
}
#[test]
fn test_validate_multiline_range_meta_lines() {
let patch = "diff --git a/f.rs b/f.rs\nindex abc..def 100644\n--- a/f.rs\n+++ b/f.rs\n@@ -1,2 +1,3 @@\n context1\n+added1\n+added2";
assert!(!validate_multiline_range(patch, 0, 5));
assert!(validate_multiline_range(patch, 5, 7));
}
#[test]
fn test_patch_index_basic_equivalence() {
let idx = PatchIndex::build(SAMPLE_PATCH);
for i in 0..6 {
let expected = get_line_info(SAMPLE_PATCH, i);
let actual = idx.get(i);
match (expected, actual) {
(Some(e), Some(a)) => {
assert_eq!(a.content, e.line_content, "line {i} content mismatch");
assert_eq!(a.line_type, e.line_type, "line {i} type mismatch");
assert_eq!(
a.new_line_number, e.new_line_number,
"line {i} new_line_number mismatch"
);
assert_eq!(
a.diff_position, e.diff_position,
"line {i} diff_position mismatch"
);
}
(None, None) => {}
_ => panic!("line {i}: one is Some, the other is None"),
}
}
}
#[test]
fn test_patch_index_multi_hunk() {
let patch = "@@ -1,3 +1,3 @@\n-old1\n+new1\n ctx\n@@ -10,3 +10,3 @@\n-old2\n+new2\n ctx2";
let idx = PatchIndex::build(patch);
for i in 0..8 {
let expected = get_line_info(patch, i);
let actual = idx.get(i);
match (expected, actual) {
(Some(e), Some(a)) => {
assert_eq!(a.content, e.line_content, "line {i} content");
assert_eq!(a.line_type, e.line_type, "line {i} type");
assert_eq!(a.new_line_number, e.new_line_number, "line {i} new_ln");
assert_eq!(a.diff_position, e.diff_position, "line {i} pos");
}
(None, None) => {}
_ => panic!("line {i}: mismatch"),
}
}
}
#[test]
fn test_patch_index_with_meta_lines() {
let patch = "diff --git a/foo.rs b/foo.rs\nindex 123..456 100644\n--- a/foo.rs\n+++ b/foo.rs\n@@ -1,2 +1,3 @@\n fn main() {\n+ println!(\"hello\");\n }";
let idx = PatchIndex::build(patch);
for i in 0..8 {
let expected = get_line_info(patch, i);
let actual = idx.get(i);
match (expected, actual) {
(Some(e), Some(a)) => {
assert_eq!(a.content, e.line_content, "line {i} content");
assert_eq!(a.line_type, e.line_type, "line {i} type");
assert_eq!(a.new_line_number, e.new_line_number, "line {i} new_ln");
assert_eq!(a.diff_position, e.diff_position, "line {i} pos");
}
(None, None) => {}
_ => panic!("line {i}: mismatch"),
}
}
}
#[test]
fn test_patch_index_empty_patch() {
let idx = PatchIndex::build("");
assert!(idx.get(0).is_none());
assert_eq!(idx.len(), 0);
}
#[test]
fn test_patch_index_crlf() {
let patch = "@@ -1,2 +1,3 @@\r\n fn main() {\r\n+ hello\r\n }";
let idx = PatchIndex::build(patch);
assert_eq!(idx.get(0).unwrap().line_type, LineType::Header);
let line1 = idx.get(1).unwrap();
assert_eq!(line1.line_type, LineType::Context);
assert_eq!(line1.content, "fn main() {");
assert_eq!(line1.new_line_number, Some(1));
let line2 = idx.get(2).unwrap();
assert_eq!(line2.line_type, LineType::Added);
assert_eq!(line2.content, " hello");
}
#[test]
fn test_patch_index_cjk_content() {
let patch = "@@ -1,2 +1,3 @@\n 日本語のコンテキスト\n+追加された行\n-削除された行";
let idx = PatchIndex::build(patch);
let line1 = idx.get(1).unwrap();
assert_eq!(line1.content, "日本語のコンテキスト");
assert_eq!(line1.line_type, LineType::Context);
let line2 = idx.get(2).unwrap();
assert_eq!(line2.content, "追加された行");
assert_eq!(line2.line_type, LineType::Added);
let line3 = idx.get(3).unwrap();
assert_eq!(line3.content, "削除された行");
assert_eq!(line3.line_type, LineType::Removed);
}
#[test]
fn test_patch_index_large_patch() {
let mut lines = vec!["@@ -1,5000 +1,5000 @@".to_string()];
for i in 0..5000 {
match i % 3 {
0 => lines.push(format!("+added line {}", i)),
1 => lines.push(format!("-removed line {}", i)),
_ => lines.push(format!(" context line {}", i)),
}
}
let patch = lines.join("\n");
let idx = PatchIndex::build(&patch);
assert_eq!(idx.len(), 5001);
for i in [0, 1, 100, 500, 2500, 4999, 5000] {
let expected = get_line_info(&patch, i);
let actual = idx.get(i);
match (expected, actual) {
(Some(e), Some(a)) => {
assert_eq!(a.content, e.line_content, "line {i}");
assert_eq!(a.line_type, e.line_type, "line {i}");
assert_eq!(a.new_line_number, e.new_line_number, "line {i}");
assert_eq!(a.diff_position, e.diff_position, "line {i}");
}
(None, None) => {}
_ => panic!("line {i}: mismatch"),
}
}
}
#[test]
fn test_patch_index_out_of_bounds() {
let idx = PatchIndex::build(SAMPLE_PATCH);
assert!(idx.get(999).is_none());
}
#[test]
fn test_parse_unified_diff_crlf() {
let diff = "diff --git a/file.rs b/file.rs\r\nindex 123..456 100644\r\n--- a/file.rs\r\n+++ b/file.rs\r\n@@ -1,2 +1,3 @@\r\n fn main() {\r\n+ hello\r\n }";
let result = parse_unified_diff(diff);
assert!(result.contains_key("file.rs"), "keys: {:?}", result.keys());
}
#[test]
fn test_parse_unified_diff_cjk_filename() {
let diff = "diff --git a/日本語ファイル.rs b/日本語ファイル.rs\nindex 123..456 100644\n--- a/日本語ファイル.rs\n+++ b/日本語ファイル.rs\n@@ -1,1 +1,2 @@\n 既存の行\n+新しい行";
let result = parse_unified_diff(diff);
assert!(
result.contains_key("日本語ファイル.rs"),
"keys: {:?}",
result.keys()
);
}
}