notedeck

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

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 }