@@ -8,13 +8,14 @@ use std::{
88 fs:: { self } ,
99 ops:: { Deref , DerefMut } ,
1010 path:: { Path , PathBuf } ,
11+ time:: Duration ,
1112} ;
1213
1314use crossterm:: style:: { ContentStyle , Stylize as _} ;
1415use fancy_regex:: RegexBuilder ;
1516use jp_tool:: { AnswerType , Outcome , Question } ;
1617use serde_json:: { Map , Value } ;
17- use similar:: { ChangeTag , TextDiff } ;
18+ use similar:: { ChangeTag , TextDiff , udiff :: UnifiedDiff } ;
1819
1920use super :: utils:: is_file_dirty;
2021use crate :: { Context , Error } ;
@@ -200,8 +201,12 @@ fn format_changes(changes: Vec<Change>, root: &Path) -> String {
200201 . into_iter ( )
201202 . map ( |change| {
202203 let path = root. join ( change. path . to_string_lossy ( ) . trim_start_matches ( '/' ) ) ;
203- let diff = file_diff ( & change. before , & change. after ) ;
204- format ! ( "{}:\n \n ```diff\n {diff}\n ```" , path. display( ) )
204+
205+ let diff = text_diff ( & change. before , & change. after ) ;
206+ let unified = unified_diff ( & diff, & path. display ( ) . to_string ( ) ) ;
207+ let colored = colored_diff ( & diff, & unified) ;
208+
209+ format ! ( "{}:\n \n ```diff\n {colored}\n ```" , path. display( ) )
205210 } )
206211 . collect :: < Vec < _ > > ( )
207212 . join ( "\n \n " )
@@ -212,12 +217,14 @@ fn apply_changes(
212217 root : & Path ,
213218 answers : & Map < String , Value > ,
214219) -> Result < Outcome , Error > {
215- let modified = changes
216- . iter ( )
217- . map ( |c| c. path . to_string_lossy ( ) . to_string ( ) )
218- . collect :: < Vec < _ > > ( ) ;
219-
220- for Change { path, after, .. } in changes {
220+ let mut modified = vec ! [ ] ;
221+ let count = changes. len ( ) ;
222+ for Change {
223+ path,
224+ after,
225+ before,
226+ } in changes
227+ {
221228 if is_file_dirty ( root, & path) ? {
222229 match answers. get ( "modify_dirty_file" ) . and_then ( Value :: as_bool) {
223230 Some ( true ) => { }
@@ -242,12 +249,28 @@ fn apply_changes(
242249 }
243250 }
244251
245- let absolute_path = root. join ( path. to_string_lossy ( ) . trim_start_matches ( '/' ) ) ;
252+ let file_path = path. to_string_lossy ( ) ;
253+ let file_path = file_path. trim_start_matches ( '/' ) ;
254+ let absolute_path = root. join ( file_path) ;
255+
256+ fs:: write ( absolute_path, & after) ?;
246257
247- fs:: write ( absolute_path, after) ?;
258+ let diff = text_diff ( & before, & after) ;
259+ let diff = unified_diff ( & diff, file_path) ;
260+
261+ modified. push ( diff. to_string ( ) ) ;
248262 }
249263
250- Ok ( format ! ( "File(s) modified successfully:\n \n {}." , modified. join( "\n " ) ) . into ( ) )
264+ Ok ( format ! (
265+ "{} modified successfully:\n \n {}" ,
266+ if count == 1 { "File" } else { "Files" } ,
267+ modified
268+ . into_iter( )
269+ . map( |diff| format!( "```diff\n {diff}```" ) )
270+ . collect:: <Vec <_>>( )
271+ . join( "\n \n " )
272+ )
273+ . into ( ) )
251274}
252275
253276struct Line ( Option < usize > ) ;
@@ -261,15 +284,32 @@ impl fmt::Display for Line {
261284 }
262285}
263286
264- fn file_diff ( old : & str , new : & str ) -> String {
265- let diff = TextDiff :: from_lines ( old, new) ;
287+ fn text_diff < ' old , ' new , ' bufs > (
288+ old : & ' old str ,
289+ new : & ' new str ,
290+ ) -> TextDiff < ' old , ' new , ' bufs , str > {
291+ similar:: TextDiff :: configure ( )
292+ . algorithm ( similar:: Algorithm :: Patience )
293+ . timeout ( Duration :: from_secs ( 2 ) )
294+ . diff_lines ( old, new)
295+ }
266296
297+ fn unified_diff < ' diff , ' old , ' new , ' bufs > (
298+ diff : & ' diff TextDiff < ' old , ' new , ' bufs , str > ,
299+ file : & str ,
300+ ) -> UnifiedDiff < ' diff , ' old , ' new , ' bufs , str > {
301+ let mut unified = diff. unified_diff ( ) ;
302+ unified. context_radius ( 3 ) . header ( file, file) ;
303+ unified
304+ }
305+
306+ fn colored_diff < ' old , ' new , ' diff : ' old + ' new , ' bufs > (
307+ diff : & ' diff TextDiff < ' old , ' new , ' bufs , str > ,
308+ unified : & UnifiedDiff < ' diff , ' old , ' new , ' bufs , str > ,
309+ ) -> String {
267310 let mut buf = String :: new ( ) ;
268- for ( idx, group) in diff. grouped_ops ( 3 ) . iter ( ) . enumerate ( ) {
269- if idx > 0 {
270- let _ = writeln ! ( buf, "{:-^1$}" , "-" , 80 ) ;
271- }
272- for op in group {
311+ for hunk in unified. iter_hunks ( ) {
312+ for op in hunk. ops ( ) {
273313 for change in diff. iter_inline_changes ( op) {
274314 let ( sign, s) = match change. tag ( ) {
275315 ChangeTag :: Delete => ( "-" , ContentStyle :: new ( ) . red ( ) ) ,
@@ -297,7 +337,6 @@ fn file_diff(old: &str, new: &str) -> String {
297337 }
298338 }
299339
300- buf. push_str ( "" . reset ( ) . to_string ( ) . as_str ( ) ) ;
301340 buf
302341}
303342
@@ -317,59 +356,43 @@ mod tests {
317356 start_content : & ' static str ,
318357 string_to_replace : & ' static str ,
319358 new_string : & ' static str ,
320- final_content : & ' static str ,
321- output : Result < & ' static str , & ' static str > ,
322359 }
323360
324361 let cases = vec ! [
325- ( "replace first line " , TestCase {
362+ ( "replace_first_line " , TestCase {
326363 start_content: "hello world\n " ,
327364 string_to_replace: "hello world" ,
328365 new_string: "hello universe" ,
329- final_content: "hello universe\n " ,
330- output: Ok ( "File(s) modified successfully:\n \n test.txt." ) ,
331366 } ) ,
332- ( "delete first line " , TestCase {
367+ ( "delete_first_line " , TestCase {
333368 start_content: "hello world\n " ,
334369 string_to_replace: "hello world" ,
335370 new_string: "" ,
336- final_content: "\n " ,
337- output: Ok ( "File(s) modified successfully:\n \n test.txt." ) ,
338371 } ) ,
339- ( "replace first line with multiple lines " , TestCase {
372+ ( "replace_first_line_with_multiple_lines " , TestCase {
340373 start_content: "hello world\n " ,
341374 string_to_replace: "hello world" ,
342375 new_string: "hello\n world\n " ,
343- final_content: "hello\n world\n " ,
344- output: Ok ( "File(s) modified successfully:\n \n test.txt." ) ,
345376 } ) ,
346- ( "replace whole line without newline " , TestCase {
377+ ( "replace_whole_line_without_newline " , TestCase {
347378 start_content: "hello world\n hello universe" ,
348379 string_to_replace: "hello world" ,
349380 new_string: "hello there" ,
350- final_content: "hello there\n hello universe" ,
351- output: Ok ( "File(s) modified successfully:\n \n test.txt." ) ,
352381 } ) ,
353- ( "replace subset of line " , TestCase {
382+ ( "replace_subset_of_line " , TestCase {
354383 start_content: "hello world how are you doing?" ,
355384 string_to_replace: "world" ,
356385 new_string: "universe" ,
357- final_content: "hello universe how are you doing?" ,
358- output: Ok ( "File(s) modified successfully:\n \n test.txt." ) ,
359386 } ) ,
360- ( "replace subset across multiple lines " , TestCase {
387+ ( "replace_subset_across_multiple_lines " , TestCase {
361388 start_content: "hello world\n how are you doing?" ,
362389 string_to_replace: "world\n how" ,
363390 new_string: "universe\n what" ,
364- final_content: "hello universe\n what are you doing?" ,
365- output: Ok ( "File(s) modified successfully:\n \n test.txt." ) ,
366391 } ) ,
367- ( "ignore replacement if no match " , TestCase {
392+ ( "ignore_replacement_if_no_match " , TestCase {
368393 start_content: "hello world how are you doing?" ,
369394 string_to_replace: "universe" ,
370395 new_string: "galaxy" ,
371- final_content: "hello world how are you doing?" ,
372- output: Err ( "Cannot find pattern to replace" ) ,
373396 } ) ,
374397 ] ;
375398
@@ -397,19 +420,24 @@ mod tests {
397420 false ,
398421 )
399422 . await
423+ . map ( |v| v. into_content ( ) . unwrap_or_default ( ) )
400424 . map_err ( |e| e. to_string ( ) ) ;
401425
402- assert_eq ! (
403- actual,
404- test_case. output. map( Into :: into) . map_err( str :: to_owned) ,
405- "test case: {name}"
406- ) ;
426+ let response = match & actual {
427+ Ok ( v) => v,
428+ Err ( e) => e,
429+ } ;
407430
408- assert_eq ! (
409- & fs:: read_to_string( & absolute_file_path) . unwrap( ) ,
410- test_case. final_content,
411- "test case: {name}"
412- ) ;
431+ insta:: with_settings!( {
432+ snapshot_suffix => name,
433+ omit_expression => true ,
434+ prepend_module_to_snapshot => false ,
435+ } , {
436+ insta:: assert_snapshot!( & response) ;
437+
438+ let file_content = fs:: read_to_string( & absolute_file_path) . unwrap( ) ;
439+ insta:: assert_snapshot!( & file_content) ;
440+ } ) ;
413441 }
414442 }
415443
@@ -505,14 +533,17 @@ mod tests {
505533 string_to_replace: r"(\w+)\s\w+" ,
506534 new_string: "$1 universe" ,
507535 final_content: "hello universe\n " ,
508- output: Ok ( "File(s) modified successfully:\n \n test.txt." ) ,
536+ output: Ok ( "File modified successfully:\n \n ```diff\n --- test.txt\n +++ \
537+ test.txt\n @@ -1 +1 @@\n -hello world\n +hello universe\n ```") ,
509538 } ) ,
510539 ( "delete" , TestCase {
511540 start_content: "hello world\n " ,
512541 string_to_replace: "h(.+?)d\n " ,
513542 new_string: "$1" ,
514543 final_content: "ello worl" ,
515- output: Ok ( "File(s) modified successfully:\n \n test.txt." ) ,
544+ output: Ok ( "File modified successfully:\n \n ```diff\n --- test.txt\n +++ \
545+ test.txt\n @@ -1 +1 @@\n -hello world\n +ello worl\n \\ No newline at \
546+ end of file\n ```") ,
516547 } ) ,
517548 ] ;
518549
@@ -542,11 +573,18 @@ mod tests {
542573 . await
543574 . map_err ( |e| e. to_string ( ) ) ;
544575
545- assert_eq ! (
546- actual,
547- test_case. output. map( Into :: into) . map_err( str :: to_owned) ,
548- "test case: {name}"
549- ) ;
576+ match ( actual, test_case. output ) {
577+ ( Ok ( Outcome :: Success { content } ) , Ok ( expected) ) => {
578+ assert_eq ! ( & content, expected, "test case: {name}" ) ;
579+ }
580+ ( actual, expected) => {
581+ assert_eq ! (
582+ actual,
583+ expected. map( Into :: into) . map_err( str :: to_owned) ,
584+ "test case: {name}"
585+ ) ;
586+ }
587+ }
550588
551589 assert_eq ! (
552590 & fs:: read_to_string( & absolute_file_path) . unwrap( ) ,
0 commit comments