file_update.rs (11186B)
1 use serde_json::Value; 2 use similar::{ChangeTag, TextDiff}; 3 4 /// Represents a proposed file modification from an AI tool call 5 #[derive(Debug, Clone)] 6 pub struct FileUpdate { 7 pub file_path: String, 8 pub update_type: FileUpdateType, 9 /// Cached diff lines (computed eagerly at construction) 10 diff_lines: Vec<DiffLine>, 11 } 12 13 #[derive(Debug, Clone)] 14 pub enum FileUpdateType { 15 /// Edit: replace old_string with new_string 16 Edit { 17 old_string: String, 18 new_string: String, 19 }, 20 /// Write: create/overwrite entire file 21 Write { content: String }, 22 } 23 24 /// A single line in a diff 25 #[derive(Debug, Clone)] 26 pub struct DiffLine { 27 pub tag: DiffTag, 28 pub content: String, 29 } 30 31 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 32 pub enum DiffTag { 33 Equal, 34 Delete, 35 Insert, 36 } 37 38 impl From<ChangeTag> for DiffTag { 39 fn from(tag: ChangeTag) -> Self { 40 match tag { 41 ChangeTag::Equal => DiffTag::Equal, 42 ChangeTag::Delete => DiffTag::Delete, 43 ChangeTag::Insert => DiffTag::Insert, 44 } 45 } 46 } 47 48 impl FileUpdate { 49 /// Create a new FileUpdate, computing the diff eagerly 50 pub fn new(file_path: String, update_type: FileUpdateType) -> Self { 51 let diff_lines = Self::compute_diff_for(&update_type); 52 Self { 53 file_path, 54 update_type, 55 diff_lines, 56 } 57 } 58 59 /// Get the cached diff lines 60 pub fn diff_lines(&self) -> &[DiffLine] { 61 &self.diff_lines 62 } 63 64 /// Try to parse a FileUpdate from a tool name and tool input JSON 65 pub fn from_tool_call(tool_name: &str, tool_input: &Value) -> Option<Self> { 66 let obj = tool_input.as_object()?; 67 68 match tool_name { 69 "Edit" => { 70 let file_path = obj.get("file_path")?.as_str()?.to_string(); 71 let old_string = obj.get("old_string")?.as_str()?.to_string(); 72 let new_string = obj.get("new_string")?.as_str()?.to_string(); 73 74 Some(FileUpdate::new( 75 file_path, 76 FileUpdateType::Edit { 77 old_string, 78 new_string, 79 }, 80 )) 81 } 82 "Write" => { 83 let file_path = obj.get("file_path")?.as_str()?.to_string(); 84 let content = obj.get("content")?.as_str()?.to_string(); 85 86 Some(FileUpdate::new( 87 file_path, 88 FileUpdateType::Write { content }, 89 )) 90 } 91 _ => None, 92 } 93 } 94 95 /// Returns true if this is an Edit that changes at most `max_lines` lines 96 /// (deleted + inserted lines). Never returns true for Write operations. 97 /// 98 /// This counts actual changed lines using a diff, not total lines in the 99 /// strings. This is important because Claude Code typically includes 100 /// surrounding context lines for matching, so even a 1-line change may 101 /// have multi-line old_string/new_string. 102 pub fn is_small_edit(&self, max_lines: usize) -> bool { 103 match &self.update_type { 104 FileUpdateType::Edit { 105 old_string, 106 new_string, 107 } => { 108 let diff = TextDiff::from_lines(old_string.as_str(), new_string.as_str()); 109 let mut deleted_lines = 0; 110 let mut inserted_lines = 0; 111 for change in diff.iter_all_changes() { 112 match change.tag() { 113 ChangeTag::Delete => deleted_lines += 1, 114 ChangeTag::Insert => inserted_lines += 1, 115 ChangeTag::Equal => {} 116 } 117 } 118 deleted_lines <= max_lines && inserted_lines <= max_lines 119 } 120 FileUpdateType::Write { .. } => false, 121 } 122 } 123 124 /// Compute the diff lines for an update type (internal helper) 125 fn compute_diff_for(update_type: &FileUpdateType) -> Vec<DiffLine> { 126 match update_type { 127 FileUpdateType::Edit { 128 old_string, 129 new_string, 130 } => { 131 let diff = TextDiff::from_lines(old_string.as_str(), new_string.as_str()); 132 diff.iter_all_changes() 133 .map(|change| DiffLine { 134 tag: change.tag().into(), 135 content: change.value().to_string(), 136 }) 137 .collect() 138 } 139 FileUpdateType::Write { content } => { 140 // For writes, everything is an insertion 141 content 142 .lines() 143 .map(|line| DiffLine { 144 tag: DiffTag::Insert, 145 content: format!("{}\n", line), 146 }) 147 .collect() 148 } 149 } 150 } 151 } 152 153 #[cfg(test)] 154 mod tests { 155 use super::*; 156 use serde_json::json; 157 158 #[test] 159 fn test_is_small_edit_single_line() { 160 let update = FileUpdate::new( 161 "test.rs".to_string(), 162 FileUpdateType::Edit { 163 old_string: "foo".to_string(), 164 new_string: "bar".to_string(), 165 }, 166 ); 167 assert!( 168 update.is_small_edit(2), 169 "Single line without newline should be small" 170 ); 171 } 172 173 #[test] 174 fn test_is_small_edit_single_line_with_newline() { 175 let update = FileUpdate::new( 176 "test.rs".to_string(), 177 FileUpdateType::Edit { 178 old_string: "foo\n".to_string(), 179 new_string: "bar\n".to_string(), 180 }, 181 ); 182 assert!( 183 update.is_small_edit(2), 184 "Single line with trailing newline should be small" 185 ); 186 } 187 188 #[test] 189 fn test_is_small_edit_two_lines() { 190 let update = FileUpdate::new( 191 "test.rs".to_string(), 192 FileUpdateType::Edit { 193 old_string: "foo\nbar".to_string(), 194 new_string: "baz\nqux".to_string(), 195 }, 196 ); 197 assert!( 198 update.is_small_edit(2), 199 "Two lines without trailing newline should be small" 200 ); 201 } 202 203 #[test] 204 fn test_is_small_edit_two_lines_with_newline() { 205 let update = FileUpdate::new( 206 "test.rs".to_string(), 207 FileUpdateType::Edit { 208 old_string: "foo\nbar\n".to_string(), 209 new_string: "baz\nqux\n".to_string(), 210 }, 211 ); 212 assert!( 213 update.is_small_edit(2), 214 "Two lines with trailing newline should be small" 215 ); 216 } 217 218 #[test] 219 fn test_is_small_edit_three_lines_not_small() { 220 let update = FileUpdate::new( 221 "test.rs".to_string(), 222 FileUpdateType::Edit { 223 old_string: "foo\nbar\nbaz".to_string(), 224 new_string: "a\nb\nc".to_string(), 225 }, 226 ); 227 assert!(!update.is_small_edit(2), "Three lines should NOT be small"); 228 } 229 230 #[test] 231 fn test_is_small_edit_write_never_small() { 232 let update = FileUpdate::new( 233 "test.rs".to_string(), 234 FileUpdateType::Write { 235 content: "x".to_string(), 236 }, 237 ); 238 assert!( 239 !update.is_small_edit(2), 240 "Write operations should never be small" 241 ); 242 } 243 244 #[test] 245 fn test_is_small_edit_old_small_new_large() { 246 let update = FileUpdate::new( 247 "test.rs".to_string(), 248 FileUpdateType::Edit { 249 old_string: "foo".to_string(), 250 new_string: "a\nb\nc\nd".to_string(), 251 }, 252 ); 253 assert!( 254 !update.is_small_edit(2), 255 "Large new_string should NOT be small" 256 ); 257 } 258 259 #[test] 260 fn test_is_small_edit_old_large_new_small() { 261 let update = FileUpdate::new( 262 "test.rs".to_string(), 263 FileUpdateType::Edit { 264 old_string: "a\nb\nc\nd".to_string(), 265 new_string: "foo".to_string(), 266 }, 267 ); 268 assert!( 269 !update.is_small_edit(2), 270 "Large old_string should NOT be small" 271 ); 272 } 273 274 #[test] 275 fn test_from_tool_call_edit() { 276 let input = json!({ 277 "file_path": "/path/to/file.rs", 278 "old_string": "old", 279 "new_string": "new" 280 }); 281 let update = FileUpdate::from_tool_call("Edit", &input).unwrap(); 282 assert_eq!(update.file_path, "/path/to/file.rs"); 283 assert!(update.is_small_edit(2)); 284 } 285 286 #[test] 287 fn test_from_tool_call_write() { 288 let input = json!({ 289 "file_path": "/path/to/file.rs", 290 "content": "content" 291 }); 292 let update = FileUpdate::from_tool_call("Write", &input).unwrap(); 293 assert_eq!(update.file_path, "/path/to/file.rs"); 294 assert!(!update.is_small_edit(2)); 295 } 296 297 #[test] 298 fn test_from_tool_call_unknown_tool() { 299 let input = json!({ 300 "file_path": "/path/to/file.rs" 301 }); 302 assert!(FileUpdate::from_tool_call("Bash", &input).is_none()); 303 } 304 305 #[test] 306 fn test_is_small_edit_realistic_claude_edit_with_context() { 307 // Claude Code typically sends old_string/new_string with context lines 308 // for matching. Even a "small" 1-line change includes context. 309 // This tests what an actual Edit tool call might look like. 310 let input = json!({ 311 "file_path": "/path/to/file.rs", 312 "old_string": " fn foo() {\n let x = 1;\n }", 313 "new_string": " fn foo() {\n let x = 2;\n }" 314 }); 315 let update = FileUpdate::from_tool_call("Edit", &input).unwrap(); 316 // Only 1 line actually changed (let x = 1 -> let x = 2) 317 // The context lines (fn foo() and }) are the same 318 assert!( 319 update.is_small_edit(2), 320 "1-line actual change should be small, even with 3 lines of context" 321 ); 322 } 323 324 #[test] 325 fn test_is_small_edit_minimal_change() { 326 // A truly minimal single-line change 327 let input = json!({ 328 "file_path": "/path/to/file.rs", 329 "old_string": "let x = 1;", 330 "new_string": "let x = 2;" 331 }); 332 let update = FileUpdate::from_tool_call("Edit", &input).unwrap(); 333 assert!( 334 update.is_small_edit(2), 335 "Single-line change should be small" 336 ); 337 } 338 339 #[test] 340 fn test_line_count_behavior() { 341 // Document how lines().count() behaves 342 assert_eq!("foo".lines().count(), 1); 343 assert_eq!("foo\n".lines().count(), 1); // trailing newline doesn't add line 344 assert_eq!("foo\nbar".lines().count(), 2); 345 assert_eq!("foo\nbar\n".lines().count(), 2); 346 assert_eq!("foo\nbar\nbaz".lines().count(), 3); 347 assert_eq!("foo\nbar\nbaz\n".lines().count(), 3); 348 // Empty strings 349 assert_eq!("".lines().count(), 0); 350 assert_eq!("\n".lines().count(), 1); // just a newline counts as 1 empty line 351 } 352 }