notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit e7c7f303e0f490e6e6f93ac8f1d4080903d30258
parent cd4ab88e27520e70dc9ce96414654f5936c4e7ee
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 28 Jan 2026 20:00:22 -0800

fix(dave): count changed lines, not total lines in is_small_edit

The auto-accept small edits feature wasn't working because is_small_edit
was counting total lines in old_string/new_string. Claude Code includes
surrounding context lines for matching, so even a 1-line change has 3+
lines. Now uses TextDiff to count only actually changed (deleted/inserted)
lines, ignoring equal context lines.

Adds comprehensive unit tests for is_small_edit and from_tool_call.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/file_update.rs | 222+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 218 insertions(+), 4 deletions(-)

diff --git a/crates/notedeck_dave/src/file_update.rs b/crates/notedeck_dave/src/file_update.rs @@ -76,16 +76,29 @@ impl FileUpdate { } /// Returns true if this is an Edit that changes at most `max_lines` lines - /// on both the old and new side. Never returns true for Write operations. + /// (deleted + inserted lines). Never returns true for Write operations. + /// + /// This counts actual changed lines using a diff, not total lines in the + /// strings. This is important because Claude Code typically includes + /// surrounding context lines for matching, so even a 1-line change may + /// have multi-line old_string/new_string. pub fn is_small_edit(&self, max_lines: usize) -> bool { match &self.update_type { FileUpdateType::Edit { old_string, new_string, } => { - let old_lines = old_string.lines().count(); - let new_lines = new_string.lines().count(); - old_lines <= max_lines && new_lines <= max_lines + let diff = TextDiff::from_lines(old_string.as_str(), new_string.as_str()); + let mut deleted_lines = 0; + let mut inserted_lines = 0; + for change in diff.iter_all_changes() { + match change.tag() { + ChangeTag::Delete => deleted_lines += 1, + ChangeTag::Insert => inserted_lines += 1, + ChangeTag::Equal => {} + } + } + deleted_lines <= max_lines && inserted_lines <= max_lines } FileUpdateType::Write { .. } => false, } @@ -119,3 +132,204 @@ impl FileUpdate { } } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_is_small_edit_single_line() { + let update = FileUpdate { + file_path: "test.rs".to_string(), + update_type: FileUpdateType::Edit { + old_string: "foo".to_string(), + new_string: "bar".to_string(), + }, + }; + assert!( + update.is_small_edit(2), + "Single line without newline should be small" + ); + } + + #[test] + fn test_is_small_edit_single_line_with_newline() { + let update = FileUpdate { + file_path: "test.rs".to_string(), + update_type: FileUpdateType::Edit { + old_string: "foo\n".to_string(), + new_string: "bar\n".to_string(), + }, + }; + assert!( + update.is_small_edit(2), + "Single line with trailing newline should be small" + ); + } + + #[test] + fn test_is_small_edit_two_lines() { + let update = FileUpdate { + file_path: "test.rs".to_string(), + update_type: FileUpdateType::Edit { + old_string: "foo\nbar".to_string(), + new_string: "baz\nqux".to_string(), + }, + }; + assert!( + update.is_small_edit(2), + "Two lines without trailing newline should be small" + ); + } + + #[test] + fn test_is_small_edit_two_lines_with_newline() { + let update = FileUpdate { + file_path: "test.rs".to_string(), + update_type: FileUpdateType::Edit { + old_string: "foo\nbar\n".to_string(), + new_string: "baz\nqux\n".to_string(), + }, + }; + assert!( + update.is_small_edit(2), + "Two lines with trailing newline should be small" + ); + } + + #[test] + fn test_is_small_edit_three_lines_not_small() { + let update = FileUpdate { + file_path: "test.rs".to_string(), + update_type: FileUpdateType::Edit { + old_string: "foo\nbar\nbaz".to_string(), + new_string: "a\nb\nc".to_string(), + }, + }; + assert!(!update.is_small_edit(2), "Three lines should NOT be small"); + } + + #[test] + fn test_is_small_edit_write_never_small() { + let update = FileUpdate { + file_path: "test.rs".to_string(), + update_type: FileUpdateType::Write { + content: "x".to_string(), + }, + }; + assert!( + !update.is_small_edit(2), + "Write operations should never be small" + ); + } + + #[test] + fn test_is_small_edit_old_small_new_large() { + let update = FileUpdate { + file_path: "test.rs".to_string(), + update_type: FileUpdateType::Edit { + old_string: "foo".to_string(), + new_string: "a\nb\nc\nd".to_string(), + }, + }; + assert!( + !update.is_small_edit(2), + "Large new_string should NOT be small" + ); + } + + #[test] + fn test_is_small_edit_old_large_new_small() { + let update = FileUpdate { + file_path: "test.rs".to_string(), + update_type: FileUpdateType::Edit { + old_string: "a\nb\nc\nd".to_string(), + new_string: "foo".to_string(), + }, + }; + assert!( + !update.is_small_edit(2), + "Large old_string should NOT be small" + ); + } + + #[test] + fn test_from_tool_call_edit() { + let input = json!({ + "file_path": "/path/to/file.rs", + "old_string": "old", + "new_string": "new" + }); + let update = FileUpdate::from_tool_call("Edit", &input).unwrap(); + assert_eq!(update.file_path, "/path/to/file.rs"); + assert!(update.is_small_edit(2)); + } + + #[test] + fn test_from_tool_call_write() { + let input = json!({ + "file_path": "/path/to/file.rs", + "content": "content" + }); + let update = FileUpdate::from_tool_call("Write", &input).unwrap(); + assert_eq!(update.file_path, "/path/to/file.rs"); + assert!(!update.is_small_edit(2)); + } + + #[test] + fn test_from_tool_call_unknown_tool() { + let input = json!({ + "file_path": "/path/to/file.rs" + }); + assert!(FileUpdate::from_tool_call("Bash", &input).is_none()); + } + + #[test] + fn test_is_small_edit_realistic_claude_edit_with_context() { + // Claude Code typically sends old_string/new_string with context lines + // for matching. Even a "small" 1-line change includes context. + // This tests what an actual Edit tool call might look like. + let input = json!({ + "file_path": "/path/to/file.rs", + "old_string": " fn foo() {\n let x = 1;\n }", + "new_string": " fn foo() {\n let x = 2;\n }" + }); + let update = FileUpdate::from_tool_call("Edit", &input).unwrap(); + // Only 1 line actually changed (let x = 1 -> let x = 2) + // The context lines (fn foo() and }) are the same + assert!( + update.is_small_edit(2), + "1-line actual change should be small, even with 3 lines of context" + ); + } + + #[test] + fn test_is_small_edit_minimal_change() { + // A truly minimal single-line change + let input = json!({ + "file_path": "/path/to/file.rs", + "old_string": "let x = 1;", + "new_string": "let x = 2;" + }); + let update = FileUpdate::from_tool_call("Edit", &input).unwrap(); + assert!( + update.is_small_edit(2), + "Single-line change should be small" + ); + } + + #[test] + fn test_line_count_behavior() { + // Document how lines().count() behaves + assert_eq!("foo".lines().count(), 1); + assert_eq!("foo\n".lines().count(), 1); // trailing newline doesn't add line + assert_eq!("foo\nbar".lines().count(), 2); + assert_eq!("foo\nbar\n".lines().count(), 2); + assert_eq!("foo\nbar\nbaz".lines().count(), 3); + assert_eq!("foo\nbar\nbaz\n".lines().count(), 3); + // Empty strings + assert_eq!("".lines().count(), 0); + assert_eq!("\n".lines().count(), 1); // just a newline counts as 1 empty line + } +}