From e272d7f6510b8eabb72369af13e840eb5cb2d2f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Oct 2025 21:50:12 +0000 Subject: [PATCH 1/3] Initial plan From 51ab821fc9d1c3db91da04faf67f3306303ab24f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Oct 2025 22:07:51 +0000 Subject: [PATCH 2/3] Extract diff parsing into dedicated src/diff module Co-authored-by: oleander <220827+oleander@users.noreply.github.com> --- examples/multi_step_commit.rs | 3 +- src/diff/mod.rs | 10 + src/diff/parser.rs | 359 ++++++++++++++++++++++++++++++++ src/diff/traits.rs | 73 +++++++ src/hook.rs | 69 +------ src/lib.rs | 1 + src/multi_step_integration.rs | 371 +--------------------------------- tests/patch_test.rs | 2 +- 8 files changed, 449 insertions(+), 439 deletions(-) create mode 100644 src/diff/mod.rs create mode 100644 src/diff/parser.rs create mode 100644 src/diff/traits.rs diff --git a/examples/multi_step_commit.rs b/examples/multi_step_commit.rs index 28ec7665..3f0c8313 100644 --- a/examples/multi_step_commit.rs +++ b/examples/multi_step_commit.rs @@ -2,7 +2,8 @@ use std::env; use anyhow::Result; use async_openai::Client; -use ai::multi_step_integration::{generate_commit_message_local, generate_commit_message_multi_step, parse_diff}; +use ai::multi_step_integration::{generate_commit_message_local, generate_commit_message_multi_step}; +use ai::diff::parser::parse_diff; #[tokio::main] async fn main() -> Result<()> { diff --git a/src/diff/mod.rs b/src/diff/mod.rs new file mode 100644 index 00000000..e8fc7f5c --- /dev/null +++ b/src/diff/mod.rs @@ -0,0 +1,10 @@ +//! Diff processing and parsing utilities. +//! +//! This module handles parsing git diffs into structured data +//! and provides utilities for working with diff content. + +pub mod parser; +pub mod traits; + +pub use parser::{ParsedFile, parse_diff}; +pub use traits::{FilePath, Utf8String, DiffDeltaPath}; \ No newline at end of file diff --git a/src/diff/parser.rs b/src/diff/parser.rs new file mode 100644 index 00000000..362dc2bc --- /dev/null +++ b/src/diff/parser.rs @@ -0,0 +1,359 @@ +//! Git diff parsing utilities. + +use anyhow::Result; + +/// Represents a parsed file from a git diff +#[derive(Debug, Clone)] +pub struct ParsedFile { + pub path: String, + pub operation: String, + pub diff_content: String, +} + +/// Extracts file path from diff header parts +/// +/// Handles various git prefixes (a/, b/, c/, i/) and /dev/null for deleted files. +/// +/// # Arguments +/// * `parts` - The whitespace-split parts from a "diff --git" line +/// +/// # Returns +/// * `Option` - The extracted path without prefixes, or None if parsing fails +fn extract_file_path_from_diff_parts(parts: &[&str]) -> Option { + if parts.len() < 4 { + return None; + } + + // Helper to strip git prefixes (a/, b/, c/, i/) + let strip_prefix = |s: &str| { + s.trim_start_matches("a/") + .trim_start_matches("b/") + .trim_start_matches("c/") + .trim_start_matches("i/") + .to_string() + }; + + let new_path = strip_prefix(parts[3]); + let old_path = strip_prefix(parts[2]); + + // Prefer new path unless it's /dev/null (deleted file) + Some(if new_path == "/dev/null" || new_path == "dev/null" { + old_path + } else { + new_path + }) +} + +/// Parse git diff into individual file changes. +/// +/// Handles various diff formats including: +/// - Standard git diff output +/// - Diffs with commit hashes +/// - Diffs with various path prefixes (a/, b/, c/, i/) +/// - Deleted files (/dev/null paths) +/// +/// # Arguments +/// * `diff_content` - Raw git diff text +/// +/// # Returns +/// * `Result>` - Parsed files or error +pub fn parse_diff(diff_content: &str) -> Result> { + let mut files = Vec::new(); + let mut current_file: Option = None; + let mut current_diff = String::new(); + + // Debug output + log::debug!("Parsing diff with {} lines", diff_content.lines().count()); + + // Add more detailed logging for debugging + if log::log_enabled!(log::Level::Debug) && !diff_content.is_empty() { + // Make sure we truncate at a valid UTF-8 character boundary + let preview = if diff_content.len() > 500 { + let truncated_index = diff_content + .char_indices() + .take_while(|(i, _)| *i < 500) + .last() + .map(|(i, c)| i + c.len_utf8()) + .unwrap_or(0); + + format!("{}... (truncated)", &diff_content[..truncated_index]) + } else { + diff_content.to_string() + }; + log::debug!("Diff content preview: \n{preview}"); + } + + // Handle different diff formats + let mut in_diff_section = false; + let mut _commit_hash_line: Option<&str> = None; + + // First scan to detect if this is a commit message with hash + for line in diff_content.lines().take(3) { + if line.len() >= 40 && line.chars().take(40).all(|c| c.is_ascii_hexdigit()) { + _commit_hash_line = Some(line); + break; + } + } + + // Process line by line + for line in diff_content.lines() { + // Skip commit hash lines and other metadata + if line.starts_with("commit ") || (line.len() >= 40 && line.chars().take(40).all(|c| c.is_ascii_hexdigit())) || line.is_empty() { + continue; + } + + // Check if we're starting a new file diff + if line.starts_with("diff --git") { + in_diff_section = true; + // Save previous file if exists + if let Some(mut file) = current_file.take() { + file.diff_content = current_diff.clone(); + log::debug!("Adding file to results: {} ({})", file.path, file.operation); + files.push(file); + current_diff.clear(); + } + + // Extract file path more carefully + let parts: Vec<&str> = line.split_whitespace().collect(); + if let Some(path) = extract_file_path_from_diff_parts(&parts) { + log::debug!("Found new file in diff: {path}"); + current_file = Some(ParsedFile { + path, + operation: "modified".to_string(), // Default, will be updated + diff_content: String::new() + }); + } + + // Add the header line to the diff content + current_diff.push_str(line); + current_diff.push('\n'); + } else if line.starts_with("new file mode") { + if let Some(ref mut file) = current_file { + log::debug!("File {} is newly added", file.path); + file.operation = "added".to_string(); + } + current_diff.push_str(line); + current_diff.push('\n'); + } else if line.starts_with("deleted file mode") { + if let Some(ref mut file) = current_file { + log::debug!("File {} is deleted", file.path); + file.operation = "deleted".to_string(); + } + current_diff.push_str(line); + current_diff.push('\n'); + } else if line.starts_with("rename from") || line.starts_with("rename to") { + if let Some(ref mut file) = current_file { + log::debug!("File {} is renamed", file.path); + file.operation = "renamed".to_string(); + } + current_diff.push_str(line); + current_diff.push('\n'); + } else if line.starts_with("Binary files") { + if let Some(ref mut file) = current_file { + log::debug!("File {} is binary", file.path); + file.operation = "binary".to_string(); + } + current_diff.push_str(line); + current_diff.push('\n'); + } else if line.starts_with("index ") || line.starts_with("--- ") || line.starts_with("+++ ") || line.starts_with("@@ ") { + // These are important diff headers that should be included + current_diff.push_str(line); + current_diff.push('\n'); + } else if in_diff_section { + current_diff.push_str(line); + current_diff.push('\n'); + } + } + + // Don't forget the last file + if let Some(mut file) = current_file { + file.diff_content = current_diff; + log::debug!("Adding final file to results: {} ({})", file.path, file.operation); + files.push(file); + } + + // If we didn't parse any files, check if this looks like a raw git diff output + // from commands like `git show` that include commit info at the top + if files.is_empty() && !diff_content.trim().is_empty() { + log::debug!("Trying to parse as raw git diff output with commit info"); + + // Extract sections that start with "diff --git" + let sections: Vec<&str> = diff_content.split("diff --git").skip(1).collect(); + + if !sections.is_empty() { + for (i, section) in sections.iter().enumerate() { + // Add the "diff --git" prefix back + let full_section = format!("diff --git{section}"); + + // Extract file path from the section more carefully + let mut found_path = false; + + // Safer approach: iterate through lines and find the path + let mut extracted_path = String::new(); + for section_line in full_section.lines().take(3) { + if section_line.starts_with("diff --git") { + let parts: Vec<&str> = section_line.split_whitespace().collect(); + if let Some(p) = extract_file_path_from_diff_parts(&parts) { + extracted_path = p; + found_path = true; + break; + } + } + } + + if found_path { + log::debug!("Found file in section {i}: {extracted_path}"); + files.push(ParsedFile { + path: extracted_path, + operation: "modified".to_string(), // Default + diff_content: full_section + }); + } + } + } + } + + // If still no files were parsed, treat the entire diff as a single change + if files.is_empty() && !diff_content.trim().is_empty() { + log::debug!("No standard diff format found, treating as single file change"); + files.push(ParsedFile { + path: "unknown".to_string(), + operation: "modified".to_string(), + diff_content: diff_content.to_string() + }); + } + + log::debug!("Parsed {} files from diff", files.len()); + + // Add detailed debug output for each parsed file + if log::log_enabled!(log::Level::Debug) { + for (i, file) in files.iter().enumerate() { + let content_preview = if file.diff_content.len() > 200 { + // Make sure we truncate at a valid UTF-8 character boundary + let truncated_index = file + .diff_content + .char_indices() + .take_while(|(i, _)| *i < 200) + .last() + .map(|(i, c)| i + c.len_utf8()) + .unwrap_or(0); + + format!("{}... (truncated)", &file.diff_content[..truncated_index]) + } else { + file.diff_content.clone() + }; + log::debug!("File {}: {} ({})\nContent preview:\n{}", i, file.path, file.operation, content_preview); + } + } + + Ok(files) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_diff() { + let diff = r#"diff --git a/src/main.rs b/src/main.rs +index 1234567..abcdefg 100644 +--- a/src/main.rs ++++ b/src/main.rs +@@ -1,5 +1,6 @@ + fn main() { +- println!("Hello"); ++ println!("Hello, world!"); ++ println!("New line"); + } +diff --git a/Cargo.toml b/Cargo.toml +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/Cargo.toml +@@ -0,0 +1,8 @@ ++[package] ++name = "test" ++version = "0.1.0" +"#; + + let files = parse_diff(diff).unwrap(); + assert_eq!(files.len(), 2); + assert_eq!(files[0].path, "src/main.rs"); + assert_eq!(files[0].operation, "modified"); + assert_eq!(files[1].path, "Cargo.toml"); + assert_eq!(files[1].operation, "added"); + + // Verify files contain diff content + assert!(!files[0].diff_content.is_empty()); + assert!(!files[1].diff_content.is_empty()); + } + + #[test] + fn test_parse_diff_with_commit_hash() { + // Test with a commit hash and message before the diff + let diff = r#"0472ffa1665c4c5573fb8f7698c9965122eda675 Update files + +diff --git a/test.js b/test.js +new file mode 100644 +index 0000000..a730e61 +--- /dev/null ++++ b/test.js +@@ -0,0 +1 @@ ++console.log('Hello'); +"#; + + let files = parse_diff(diff).unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, "test.js"); + assert_eq!(files[0].operation, "added"); + } + + #[test] + fn test_parse_diff_with_c_i_prefixes() { + // Test with c/ and i/ prefixes that appear in git hook diffs + let diff = r#"diff --git c/test.md i/test.md +new file mode 100644 +index 0000000..6c61a60 +--- /dev/null ++++ i/test.md +@@ -0,0 +1 @@ ++# Test File + +diff --git c/test.js i/test.js +new file mode 100644 +index 0000000..a730e61 +--- /dev/null ++++ i/test.js +@@ -0,0 +1 @@ ++console.log('Hello'); +"#; + + let files = parse_diff(diff).unwrap(); + assert_eq!(files.len(), 2); + assert_eq!(files[0].path, "test.md", "Should extract clean path without c/ prefix"); + assert_eq!(files[0].operation, "added"); + assert_eq!(files[1].path, "test.js", "Should extract clean path without i/ prefix"); + assert_eq!(files[1].operation, "added"); + + // Verify files contain diff content + assert!(files[0].diff_content.contains("# Test File")); + assert!(files[1].diff_content.contains("console.log")); + } + + #[test] + fn test_parse_diff_with_deleted_file() { + let diff = r#"diff --git a/test.txt b/test.txt +deleted file mode 100644 +index 9daeafb..0000000 +--- a/test.txt ++++ /dev/null +@@ -1 +0,0 @@ +-test +"#; + + let files = parse_diff(diff).unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, "test.txt"); + assert_eq!(files[0].operation, "deleted"); + } +} \ No newline at end of file diff --git a/src/diff/traits.rs b/src/diff/traits.rs new file mode 100644 index 00000000..9ca42bb0 --- /dev/null +++ b/src/diff/traits.rs @@ -0,0 +1,73 @@ +//! Utility traits for diff processing. + +use std::path::PathBuf; +use std::fs::File; +use std::io::{Read, Write}; +use anyhow::Result; + +/// Extension trait for PathBuf to support file operations needed for commits +pub trait FilePath { + fn is_empty(&self) -> Result { + self.read().map(|s| s.is_empty()) + } + + fn write(&self, msg: String) -> Result<()>; + fn read(&self) -> Result; +} + +impl FilePath for PathBuf { + fn write(&self, msg: String) -> Result<()> { + File::create(self)? + .write_all(msg.as_bytes()) + .map_err(Into::into) + } + + fn read(&self) -> Result { + let mut contents = String::new(); + File::open(self)?.read_to_string(&mut contents)?; + Ok(contents) + } +} + +/// Extension trait for git2::DiffDelta to get file paths +pub trait DiffDeltaPath { + fn path(&self) -> PathBuf; +} + +impl DiffDeltaPath for git2::DiffDelta<'_> { + fn path(&self) -> PathBuf { + self + .new_file() + .path() + .or_else(|| self.old_file().path()) + .map(PathBuf::from) + .unwrap_or_default() + } +} + +/// Extension trait for converting bytes to UTF-8 strings +pub trait Utf8String { + fn to_utf8(&self) -> String; +} + +impl Utf8String for Vec { + fn to_utf8(&self) -> String { + // Fast path for valid UTF-8 (most common case) + if let Ok(s) = std::str::from_utf8(self) { + return s.to_string(); + } + // Fallback for invalid UTF-8 + String::from_utf8_lossy(self).into_owned() + } +} + +impl Utf8String for [u8] { + fn to_utf8(&self) -> String { + // Fast path for valid UTF-8 (most common case) + if let Ok(s) = std::str::from_utf8(self) { + return s.to_string(); + } + // Fallback for invalid UTF-8 + String::from_utf8_lossy(self).into_owned() + } +} \ No newline at end of file diff --git a/src/hook.rs b/src/hook.rs index f554c653..acbb67be 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -1,7 +1,5 @@ use std::collections::HashMap; -use std::io::{Read, Write}; use std::path::PathBuf; -use std::fs::File; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -13,6 +11,7 @@ use num_cpus; use crate::model::Model; use crate::profile; +use crate::diff::traits::{Utf8String, DiffDeltaPath}; // Constants @@ -42,72 +41,6 @@ pub enum HookError { Anyhow(#[from] anyhow::Error) } -// File operations traits -pub trait FilePath { - fn is_empty(&self) -> Result { - self.read().map(|s| s.is_empty()) - } - - fn write(&self, msg: String) -> Result<()>; - fn read(&self) -> Result; -} - -impl FilePath for PathBuf { - fn write(&self, msg: String) -> Result<()> { - File::create(self)? - .write_all(msg.as_bytes()) - .map_err(Into::into) - } - - fn read(&self) -> Result { - let mut contents = String::new(); - File::open(self)?.read_to_string(&mut contents)?; - Ok(contents) - } -} - -// Git operations traits -trait DiffDeltaPath { - fn path(&self) -> PathBuf; -} - -impl DiffDeltaPath for git2::DiffDelta<'_> { - fn path(&self) -> PathBuf { - self - .new_file() - .path() - .or_else(|| self.old_file().path()) - .map(PathBuf::from) - .unwrap_or_default() - } -} - -// String conversion traits -pub trait Utf8String { - fn to_utf8(&self) -> String; -} - -impl Utf8String for Vec { - fn to_utf8(&self) -> String { - // Fast path for valid UTF-8 (most common case) - if let Ok(s) = std::str::from_utf8(self) { - return s.to_string(); - } - // Fallback for invalid UTF-8 - String::from_utf8_lossy(self).into_owned() - } -} - -impl Utf8String for [u8] { - fn to_utf8(&self) -> String { - // Fast path for valid UTF-8 (most common case) - if let Ok(s) = std::str::from_utf8(self) { - return s.to_string(); - } - // Fallback for invalid UTF-8 - String::from_utf8_lossy(self).into_owned() - } -} // Patch generation traits pub trait PatchDiff { diff --git a/src/lib.rs b/src/lib.rs index 7081bf59..b816a7c5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod commit; pub mod config; +pub mod diff; pub mod hook; pub mod style; pub mod model; diff --git a/src/multi_step_integration.rs b/src/multi_step_integration.rs index b544affb..e42c69de 100644 --- a/src/multi_step_integration.rs +++ b/src/multi_step_integration.rs @@ -10,14 +10,9 @@ use crate::multi_step_analysis::{ }; use crate::function_calling::{create_commit_function_tool, CommitFunctionArgs}; use crate::debug_output; +use crate::diff::parser::{ParsedFile, parse_diff}; + -/// Represents a parsed file from the git diff -#[derive(Debug)] -pub struct ParsedFile { - pub path: String, - pub operation: String, - pub diff_content: String -} /// Main entry point for multi-step commit message generation pub async fn generate_commit_message_multi_step( @@ -175,231 +170,7 @@ pub async fn generate_commit_message_multi_step( Ok(final_message) } -/// Extracts the file path from git diff header parts. -/// Handles various git prefixes (a/, b/, c/, i/) and /dev/null for deleted files. -/// -/// # Arguments -/// * `parts` - The whitespace-split parts from a "diff --git" line -/// -/// # Returns -/// * `Option` - The extracted path without prefixes, or None if parsing fails -fn extract_file_path_from_diff_parts(parts: &[&str]) -> Option { - if parts.len() < 4 { - return None; - } - - // Helper to strip git prefixes (a/, b/, c/, i/) - let strip_prefix = |s: &str| { - s.trim_start_matches("a/") - .trim_start_matches("b/") - .trim_start_matches("c/") - .trim_start_matches("i/") - .to_string() - }; - - let new_path = strip_prefix(parts[3]); - let old_path = strip_prefix(parts[2]); - - // Prefer new path unless it's /dev/null (deleted file) - Some(if new_path == "/dev/null" || new_path == "dev/null" { - old_path - } else { - new_path - }) -} -/// Parse git diff into individual files -pub fn parse_diff(diff_content: &str) -> Result> { - let mut files = Vec::new(); - let mut current_file: Option = None; - let mut current_diff = String::new(); - - // Debug output - log::debug!("Parsing diff with {} lines", diff_content.lines().count()); - - // Add more detailed logging for debugging - if log::log_enabled!(log::Level::Debug) && !diff_content.is_empty() { - // Make sure we truncate at a valid UTF-8 character boundary - let preview = if diff_content.len() > 500 { - let truncated_index = diff_content - .char_indices() - .take_while(|(i, _)| *i < 500) - .last() - .map(|(i, c)| i + c.len_utf8()) - .unwrap_or(0); - - format!("{}... (truncated)", &diff_content[..truncated_index]) - } else { - diff_content.to_string() - }; - log::debug!("Diff content preview: \n{preview}"); - } - - // Handle different diff formats - let mut in_diff_section = false; - let mut _commit_hash_line: Option<&str> = None; - - // First scan to detect if this is a commit message with hash - for line in diff_content.lines().take(3) { - if line.len() >= 40 && line.chars().take(40).all(|c| c.is_ascii_hexdigit()) { - _commit_hash_line = Some(line); - break; - } - } - - // Process line by line - for line in diff_content.lines() { - // Skip commit hash lines and other metadata - if line.starts_with("commit ") || (line.len() >= 40 && line.chars().take(40).all(|c| c.is_ascii_hexdigit())) || line.is_empty() { - continue; - } - - // Check if we're starting a new file diff - if line.starts_with("diff --git") { - in_diff_section = true; - // Save previous file if exists - if let Some(mut file) = current_file.take() { - file.diff_content = current_diff.clone(); - log::debug!("Adding file to results: {} ({})", file.path, file.operation); - files.push(file); - current_diff.clear(); - } - - // Extract file path more carefully - let parts: Vec<&str> = line.split_whitespace().collect(); - if let Some(path) = extract_file_path_from_diff_parts(&parts) { - log::debug!("Found new file in diff: {path}"); - current_file = Some(ParsedFile { - path, - operation: "modified".to_string(), // Default, will be updated - diff_content: String::new() - }); - } - - // Add the header line to the diff content - current_diff.push_str(line); - current_diff.push('\n'); - } else if line.starts_with("new file mode") { - if let Some(ref mut file) = current_file { - log::debug!("File {} is newly added", file.path); - file.operation = "added".to_string(); - } - current_diff.push_str(line); - current_diff.push('\n'); - } else if line.starts_with("deleted file mode") { - if let Some(ref mut file) = current_file { - log::debug!("File {} is deleted", file.path); - file.operation = "deleted".to_string(); - } - current_diff.push_str(line); - current_diff.push('\n'); - } else if line.starts_with("rename from") || line.starts_with("rename to") { - if let Some(ref mut file) = current_file { - log::debug!("File {} is renamed", file.path); - file.operation = "renamed".to_string(); - } - current_diff.push_str(line); - current_diff.push('\n'); - } else if line.starts_with("Binary files") { - if let Some(ref mut file) = current_file { - log::debug!("File {} is binary", file.path); - file.operation = "binary".to_string(); - } - current_diff.push_str(line); - current_diff.push('\n'); - } else if line.starts_with("index ") || line.starts_with("--- ") || line.starts_with("+++ ") || line.starts_with("@@ ") { - // These are important diff headers that should be included - current_diff.push_str(line); - current_diff.push('\n'); - } else if in_diff_section { - current_diff.push_str(line); - current_diff.push('\n'); - } - } - - // Don't forget the last file - if let Some(mut file) = current_file { - file.diff_content = current_diff; - log::debug!("Adding final file to results: {} ({})", file.path, file.operation); - files.push(file); - } - - // If we didn't parse any files, check if this looks like a raw git diff output - // from commands like `git show` that include commit info at the top - if files.is_empty() && !diff_content.trim().is_empty() { - log::debug!("Trying to parse as raw git diff output with commit info"); - - // Extract sections that start with "diff --git" - let sections: Vec<&str> = diff_content.split("diff --git").skip(1).collect(); - - if !sections.is_empty() { - for (i, section) in sections.iter().enumerate() { - // Add the "diff --git" prefix back - let full_section = format!("diff --git{section}"); - - // Extract file path from the section more carefully - let mut found_path = false; - - // Safer approach: iterate through lines and find the path - let mut extracted_path = String::new(); - for section_line in full_section.lines().take(3) { - if section_line.starts_with("diff --git") { - let parts: Vec<&str> = section_line.split_whitespace().collect(); - if let Some(p) = extract_file_path_from_diff_parts(&parts) { - extracted_path = p; - found_path = true; - break; - } - } - } - - if found_path { - log::debug!("Found file in section {i}: {extracted_path}"); - files.push(ParsedFile { - path: extracted_path, - operation: "modified".to_string(), // Default - diff_content: full_section - }); - } - } - } - } - - // If still no files were parsed, treat the entire diff as a single change - if files.is_empty() && !diff_content.trim().is_empty() { - log::debug!("No standard diff format found, treating as single file change"); - files.push(ParsedFile { - path: "unknown".to_string(), - operation: "modified".to_string(), - diff_content: diff_content.to_string() - }); - } - - log::debug!("Parsed {} files from diff", files.len()); - - // Add detailed debug output for each parsed file - if log::log_enabled!(log::Level::Debug) { - for (i, file) in files.iter().enumerate() { - let content_preview = if file.diff_content.len() > 200 { - // Make sure we truncate at a valid UTF-8 character boundary - let truncated_index = file - .diff_content - .char_indices() - .take_while(|(i, _)| *i < 200) - .last() - .map(|(i, c)| i + c.len_utf8()) - .unwrap_or(0); - - format!("{}... (truncated)", &file.diff_content[..truncated_index]) - } else { - file.diff_content.clone() - }; - log::debug!("File {}: {} ({})\nContent preview:\n{}", i, file.path, file.operation, content_preview); - } - } - - Ok(files) -} /// Call the analyze function via OpenAI async fn call_analyze_function(client: &Client, model: &str, file: &ParsedFile) -> Result { @@ -639,144 +410,6 @@ pub fn generate_commit_message_local(diff_content: &str, max_length: Option Result { -"#; - - let files = parse_diff(diff).unwrap(); - assert_eq!(files.len(), 1); - assert_eq!(files[0].path, "src/openai.rs"); - assert_eq!(files[0].operation, "modified"); - - // Verify diff content contains actual changes - assert!(files[0].diff_content.contains("pub struct Response")); - - // Verify commit hash line was skipped - assert!(!files[0] - .diff_content - .contains("0472ffa1665c4c5573fb8f7698c9965122eda675")); - } - - #[test] - fn test_parse_diff_with_c_i_prefixes() { - // Test with c/ and i/ prefixes that appear in git hook diffs - let diff = r#"diff --git c/test.md i/test.md -new file mode 100644 -index 0000000..6c61a60 ---- /dev/null -+++ i/test.md -@@ -0,0 +1 @@ -+# Test File - -diff --git c/test.js i/test.js -new file mode 100644 -index 0000000..a730e61 ---- /dev/null -+++ i/test.js -@@ -0,0 +1 @@ -+console.log('Hello'); -"#; - - let files = parse_diff(diff).unwrap(); - assert_eq!(files.len(), 2); - assert_eq!(files[0].path, "test.md", "Should extract clean path without i/ prefix"); - assert_eq!(files[0].operation, "added"); - assert_eq!(files[1].path, "test.js", "Should extract clean path without i/ prefix"); - assert_eq!(files[1].operation, "added"); - - // Verify files contain diff content - assert!(files[0].diff_content.contains("# Test File")); - assert!(files[1].diff_content.contains("console.log")); - } - - #[test] - fn test_parse_diff_with_deleted_file() { - // Test with a deleted file (where b path is /dev/null) - let diff = r#"diff --git a/deleted.txt b/dev/null -deleted file mode 100644 -index 1234567..0000000 ---- a/deleted.txt -+++ /dev/null -@@ -1,3 +0,0 @@ --This file --will be --deleted -"#; - - let files = parse_diff(diff).unwrap(); - assert_eq!(files.len(), 1); - assert_eq!(files[0].path, "deleted.txt", "Should use a path for deleted files"); - assert_eq!(files[0].operation, "deleted"); - - // Verify file contains diff content - assert!(files[0].diff_content.contains("This file")); - } - #[test] fn test_local_generation() { let diff = r#"diff --git a/src/auth.rs b/src/auth.rs diff --git a/tests/patch_test.rs b/tests/patch_test.rs index f4d7420d..9d819c05 100644 --- a/tests/patch_test.rs +++ b/tests/patch_test.rs @@ -3,7 +3,7 @@ mod common; use tempfile::NamedTempFile; use anyhow::{Context, Result}; use git2::{DiffOptions, Repository, Tree}; -use ai::hook::*; +use ai::diff::traits::{FilePath, Utf8String}; use common::*; #[test] From 937db808a28f882a758e2f7342485f8fe48d9a9a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Oct 2025 00:19:28 +0000 Subject: [PATCH 3/3] fix: remove needless borrows in model validation test to fix CI clippy warnings Co-authored-by: oleander <220827+oleander@users.noreply.github.com> --- tests/model_validation_test.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/model_validation_test.rs b/tests/model_validation_test.rs index 6497b4e2..510fa749 100644 --- a/tests/model_validation_test.rs +++ b/tests/model_validation_test.rs @@ -81,8 +81,8 @@ fn test_model_as_ref() { s.as_ref().to_string() } - assert_eq!(takes_str_ref(&Model::GPT41), "gpt-4.1"); - assert_eq!(takes_str_ref(&Model::GPT41Mini), "gpt-4.1-mini"); + assert_eq!(takes_str_ref(Model::GPT41), "gpt-4.1"); + assert_eq!(takes_str_ref(Model::GPT41Mini), "gpt-4.1-mini"); } #[test]