@@ -6,6 +6,7 @@ use crossterm::queue;
6
6
use crossterm:: style:: {
7
7
self ,
8
8
Color ,
9
+ Stylize ,
9
10
} ;
10
11
use eyre:: {
11
12
Result ,
@@ -28,13 +29,17 @@ use super::{
28
29
format_path,
29
30
sanitize_path_tool_arg,
30
31
} ;
32
+ use crate :: cli:: chat:: CONTINUATION_LINE ;
31
33
use crate :: cli:: chat:: util:: images:: {
32
34
handle_images_from_paths,
33
35
is_supported_image_type,
34
36
pre_process,
35
37
} ;
36
38
use crate :: platform:: Context ;
37
39
40
+ const CHECKMARK : & str = "✔" ;
41
+ const CROSS : & str = "✘" ;
42
+
38
43
#[ derive( Debug , Clone , Deserialize ) ]
39
44
#[ serde( tag = "mode" ) ]
40
45
pub enum FsRead {
@@ -145,7 +150,9 @@ impl FsLine {
145
150
146
151
pub async fn queue_description ( & self , ctx : & Context , updates : & mut impl Write ) -> Result < ( ) > {
147
152
let path = sanitize_path_tool_arg ( ctx, & self . path ) ;
148
- let line_count = ctx. fs ( ) . read_to_string ( & path) . await ?. lines ( ) . count ( ) ;
153
+ let file_bytes = ctx. fs ( ) . read ( & path) . await ?;
154
+ let file_content = String :: from_utf8_lossy ( & file_bytes) ;
155
+ let line_count = file_content. lines ( ) . count ( ) ;
149
156
queue ! (
150
157
updates,
151
158
style:: Print ( "Reading file: " ) ,
@@ -184,8 +191,9 @@ impl FsLine {
184
191
pub async fn invoke ( & self , ctx : & Context , _updates : & mut impl Write ) -> Result < InvokeOutput > {
185
192
let path = sanitize_path_tool_arg ( ctx, & self . path ) ;
186
193
debug ! ( ?path, "Reading" ) ;
187
- let file = ctx. fs ( ) . read_to_string ( & path) . await ?;
188
- let line_count = file. lines ( ) . count ( ) ;
194
+ let file_bytes = ctx. fs ( ) . read ( & path) . await ?;
195
+ let file_content = String :: from_utf8_lossy ( & file_bytes) ;
196
+ let line_count = file_content. lines ( ) . count ( ) ;
189
197
let ( start, end) = (
190
198
convert_negative_index ( line_count, self . start_line ( ) ) ,
191
199
convert_negative_index ( line_count, self . end_line ( ) ) ,
@@ -204,7 +212,7 @@ impl FsLine {
204
212
}
205
213
206
214
// The range should be inclusive on both ends.
207
- let file_contents = file
215
+ let file_contents = file_content
208
216
. lines ( )
209
217
. skip ( start)
210
218
. take ( end - start + 1 )
@@ -272,16 +280,17 @@ impl FsSearch {
272
280
style:: SetForegroundColor ( Color :: Green ) ,
273
281
style:: Print ( & self . pattern. to_lowercase( ) ) ,
274
282
style:: ResetColor ,
283
+ style:: Print ( "\n " ) ,
275
284
) ?;
276
285
Ok ( ( ) )
277
286
}
278
287
279
288
pub async fn invoke ( & self , ctx : & Context , updates : & mut impl Write ) -> Result < InvokeOutput > {
280
289
let file_path = sanitize_path_tool_arg ( ctx, & self . path ) ;
281
290
let pattern = & self . pattern ;
282
- let relative_path = format_path ( ctx. env ( ) . current_dir ( ) ?, & file_path) ;
283
291
284
- let file_content = ctx. fs ( ) . read_to_string ( & file_path) . await ?;
292
+ let file_bytes = ctx. fs ( ) . read ( & file_path) . await ?;
293
+ let file_content = String :: from_utf8_lossy ( & file_bytes) ;
285
294
let lines: Vec < & str > = LinesWithEndings :: from ( & file_content) . collect ( ) ;
286
295
287
296
let mut results = Vec :: new ( ) ;
@@ -311,16 +320,35 @@ impl FsSearch {
311
320
} ) ;
312
321
}
313
322
}
323
+ let match_text = if total_matches == 1 {
324
+ "1 match" . to_string ( )
325
+ } else {
326
+ format ! ( "{} matches" , total_matches)
327
+ } ;
328
+
329
+ let color = if total_matches == 0 {
330
+ Color :: Yellow
331
+ } else {
332
+ Color :: Green
333
+ } ;
334
+
335
+ let result = if total_matches == 0 {
336
+ CROSS . yellow ( )
337
+ } else {
338
+ CHECKMARK . green ( )
339
+ } ;
314
340
315
341
queue ! (
316
342
updates,
317
343
style:: SetForegroundColor ( Color :: Yellow ) ,
318
344
style:: ResetColor ,
319
- style:: Print ( format!(
320
- "Found {} matches for pattern '{}' in {}\n " ,
321
- total_matches, pattern, relative_path
322
- ) ) ,
345
+ style:: Print ( CONTINUATION_LINE ) ,
323
346
style:: Print ( "\n " ) ,
347
+ style:: Print ( " " ) ,
348
+ style:: Print ( result) ,
349
+ style:: Print ( " Found: " ) ,
350
+ style:: SetForegroundColor ( color) ,
351
+ style:: Print ( match_text) ,
324
352
style:: ResetColor ,
325
353
) ?;
326
354
@@ -741,4 +769,250 @@ mod tests {
741
769
)
742
770
) ;
743
771
}
772
+
773
+ #[ tokio:: test]
774
+ async fn test_fs_read_non_utf8_binary_file ( ) {
775
+ let ctx = Context :: builder ( ) . with_test_home ( ) . await . unwrap ( ) . build_fake ( ) ;
776
+ let fs = ctx. fs ( ) ;
777
+ let mut stdout = std:: io:: stdout ( ) ;
778
+
779
+ let binary_data = vec ! [ 0xff , 0xfe , 0xfd , 0xfc , 0xfb , 0xfa , 0xf9 , 0xf8 ] ;
780
+ let binary_file_path = "/binary_test.dat" ;
781
+ fs. write ( binary_file_path, & binary_data) . await . unwrap ( ) ;
782
+
783
+ let v = serde_json:: json!( {
784
+ "path" : binary_file_path,
785
+ "mode" : "Line"
786
+ } ) ;
787
+ let output = serde_json:: from_value :: < FsRead > ( v)
788
+ . unwrap ( )
789
+ . invoke ( & ctx, & mut stdout)
790
+ . await
791
+ . unwrap ( ) ;
792
+
793
+ if let OutputKind :: Text ( text) = output. output {
794
+ assert ! ( text. contains( '�' ) , "Binary data should contain replacement characters" ) ;
795
+ assert_eq ! ( text. chars( ) . count( ) , 8 , "Should have 8 replacement characters" ) ;
796
+ assert ! (
797
+ text. chars( ) . all( |c| c == '�' ) ,
798
+ "All characters should be replacement characters"
799
+ ) ;
800
+ } else {
801
+ panic ! ( "expected text output" ) ;
802
+ }
803
+ }
804
+
805
+ #[ tokio:: test]
806
+ async fn test_fs_read_latin1_encoded_file ( ) {
807
+ let ctx = Context :: builder ( ) . with_test_home ( ) . await . unwrap ( ) . build_fake ( ) ;
808
+ let fs = ctx. fs ( ) ;
809
+ let mut stdout = std:: io:: stdout ( ) ;
810
+
811
+ let latin1_data = vec ! [ 99 , 97 , 102 , 233 ] ; // "café" in Latin-1
812
+ let latin1_file_path = "/latin1_test.txt" ;
813
+ fs. write ( latin1_file_path, & latin1_data) . await . unwrap ( ) ;
814
+
815
+ let v = serde_json:: json!( {
816
+ "path" : latin1_file_path,
817
+ "mode" : "Line"
818
+ } ) ;
819
+ let output = serde_json:: from_value :: < FsRead > ( v)
820
+ . unwrap ( )
821
+ . invoke ( & ctx, & mut stdout)
822
+ . await
823
+ . unwrap ( ) ;
824
+
825
+ if let OutputKind :: Text ( text) = output. output {
826
+ // Latin-1 byte 233 (é) is invalid UTF-8, so it becomes a replacement character
827
+ assert ! ( text. starts_with( "caf" ) , "Should start with 'caf'" ) ;
828
+ assert ! (
829
+ text. contains( '�' ) ,
830
+ "Should contain replacement character for invalid UTF-8"
831
+ ) ;
832
+ } else {
833
+ panic ! ( "expected text output" ) ;
834
+ }
835
+ }
836
+
837
+ #[ tokio:: test]
838
+ async fn test_fs_search_non_utf8_file ( ) {
839
+ let ctx = Context :: builder ( ) . with_test_home ( ) . await . unwrap ( ) . build_fake ( ) ;
840
+ let fs = ctx. fs ( ) ;
841
+ let mut stdout = std:: io:: stdout ( ) ;
842
+
843
+ let mut mixed_data = Vec :: new ( ) ;
844
+ mixed_data. extend_from_slice ( b"Hello world\n " ) ;
845
+ mixed_data. extend_from_slice ( & [ 0xff , 0xfe ] ) ; // Invalid UTF-8 bytes
846
+ mixed_data. extend_from_slice ( b"\n Goodbye world\n " ) ;
847
+
848
+ let mixed_file_path = "/mixed_encoding_test.txt" ;
849
+ fs. write ( mixed_file_path, & mixed_data) . await . unwrap ( ) ;
850
+
851
+ let v = serde_json:: json!( {
852
+ "mode" : "Search" ,
853
+ "path" : mixed_file_path,
854
+ "pattern" : "hello"
855
+ } ) ;
856
+ let output = serde_json:: from_value :: < FsRead > ( v)
857
+ . unwrap ( )
858
+ . invoke ( & ctx, & mut stdout)
859
+ . await
860
+ . unwrap ( ) ;
861
+
862
+ if let OutputKind :: Text ( value) = output. output {
863
+ let matches: Vec < SearchMatch > = serde_json:: from_str ( & value) . unwrap ( ) ;
864
+ assert_eq ! ( matches. len( ) , 1 , "Should find one match for 'hello'" ) ;
865
+ assert_eq ! ( matches[ 0 ] . line_number, 1 , "Match should be on line 1" ) ;
866
+ assert ! (
867
+ matches[ 0 ] . context. contains( "Hello world" ) ,
868
+ "Should contain the matched line"
869
+ ) ;
870
+ } else {
871
+ panic ! ( "expected Text output" ) ;
872
+ }
873
+
874
+ let v = serde_json:: json!( {
875
+ "mode" : "Search" ,
876
+ "path" : mixed_file_path,
877
+ "pattern" : "goodbye"
878
+ } ) ;
879
+ let output = serde_json:: from_value :: < FsRead > ( v)
880
+ . unwrap ( )
881
+ . invoke ( & ctx, & mut stdout)
882
+ . await
883
+ . unwrap ( ) ;
884
+
885
+ if let OutputKind :: Text ( value) = output. output {
886
+ let matches: Vec < SearchMatch > = serde_json:: from_str ( & value) . unwrap ( ) ;
887
+ assert_eq ! ( matches. len( ) , 1 , "Should find one match for 'goodbye'" ) ;
888
+ assert ! (
889
+ matches[ 0 ] . context. contains( "Goodbye world" ) ,
890
+ "Should contain the matched line"
891
+ ) ;
892
+ } else {
893
+ panic ! ( "expected Text output" ) ;
894
+ }
895
+ }
896
+
897
+ #[ tokio:: test]
898
+ async fn test_fs_read_windows1252_encoded_file ( ) {
899
+ let ctx = Context :: builder ( ) . with_test_home ( ) . await . unwrap ( ) . build_fake ( ) ;
900
+ let fs = ctx. fs ( ) ;
901
+ let mut stdout = std:: io:: stdout ( ) ;
902
+
903
+ let mut windows1252_data = Vec :: new ( ) ;
904
+ windows1252_data. extend_from_slice ( b"Text with " ) ;
905
+ windows1252_data. push ( 0x93 ) ; // Left double quotation mark in Windows-1252
906
+ windows1252_data. extend_from_slice ( b"smart quotes" ) ;
907
+ windows1252_data. push ( 0x94 ) ; // Right double quotation mark in Windows-1252
908
+
909
+ let windows1252_file_path = "/windows1252_test.txt" ;
910
+ fs. write ( windows1252_file_path, & windows1252_data) . await . unwrap ( ) ;
911
+
912
+ let v = serde_json:: json!( {
913
+ "path" : windows1252_file_path,
914
+ "mode" : "Line"
915
+ } ) ;
916
+ let output = serde_json:: from_value :: < FsRead > ( v)
917
+ . unwrap ( )
918
+ . invoke ( & ctx, & mut stdout)
919
+ . await
920
+ . unwrap ( ) ;
921
+
922
+ if let OutputKind :: Text ( text) = output. output {
923
+ assert ! ( text. contains( "Text with" ) , "Should contain readable text" ) ;
924
+ assert ! ( text. contains( "smart quotes" ) , "Should contain readable text" ) ;
925
+ assert ! (
926
+ text. contains( '�' ) ,
927
+ "Should contain replacement characters for invalid UTF-8"
928
+ ) ;
929
+ } else {
930
+ panic ! ( "expected text output" ) ;
931
+ }
932
+ }
933
+
934
+ #[ tokio:: test]
935
+ async fn test_fs_search_pattern_with_replacement_chars ( ) {
936
+ let ctx = Context :: builder ( ) . with_test_home ( ) . await . unwrap ( ) . build_fake ( ) ;
937
+ let fs = ctx. fs ( ) ;
938
+ let mut stdout = std:: io:: stdout ( ) ;
939
+
940
+ let mut data_with_invalid_utf8 = Vec :: new ( ) ;
941
+ data_with_invalid_utf8. extend_from_slice ( b"Line 1: caf" ) ;
942
+ data_with_invalid_utf8. push ( 0xe9 ) ; // Invalid UTF-8 byte (Latin-1 é)
943
+ data_with_invalid_utf8. extend_from_slice ( b"\n Line 2: hello world\n " ) ;
944
+
945
+ let invalid_utf8_file_path = "/invalid_utf8_search_test.txt" ;
946
+ fs. write ( invalid_utf8_file_path, & data_with_invalid_utf8) . await . unwrap ( ) ;
947
+
948
+ let v = serde_json:: json!( {
949
+ "mode" : "Search" ,
950
+ "path" : invalid_utf8_file_path,
951
+ "pattern" : "caf"
952
+ } ) ;
953
+ let output = serde_json:: from_value :: < FsRead > ( v)
954
+ . unwrap ( )
955
+ . invoke ( & ctx, & mut stdout)
956
+ . await
957
+ . unwrap ( ) ;
958
+
959
+ if let OutputKind :: Text ( value) = output. output {
960
+ let matches: Vec < SearchMatch > = serde_json:: from_str ( & value) . unwrap ( ) ;
961
+ assert_eq ! ( matches. len( ) , 1 , "Should find one match for 'caf'" ) ;
962
+ assert_eq ! ( matches[ 0 ] . line_number, 1 , "Match should be on line 1" ) ;
963
+ assert ! ( matches[ 0 ] . context. contains( "caf" ) , "Should contain 'caf'" ) ;
964
+ } else {
965
+ panic ! ( "expected Text output" ) ;
966
+ }
967
+ }
968
+
969
+ #[ tokio:: test]
970
+ async fn test_fs_read_empty_file_with_invalid_utf8 ( ) {
971
+ let ctx = Context :: builder ( ) . with_test_home ( ) . await . unwrap ( ) . build_fake ( ) ;
972
+ let fs = ctx. fs ( ) ;
973
+ let mut stdout = std:: io:: stdout ( ) ;
974
+
975
+ let invalid_only_data = vec ! [ 0xff , 0xfe , 0xfd ] ;
976
+ let invalid_only_file_path = "/invalid_only_test.txt" ;
977
+ fs. write ( invalid_only_file_path, & invalid_only_data) . await . unwrap ( ) ;
978
+
979
+ let v = serde_json:: json!( {
980
+ "path" : invalid_only_file_path,
981
+ "mode" : "Line"
982
+ } ) ;
983
+ let output = serde_json:: from_value :: < FsRead > ( v)
984
+ . unwrap ( )
985
+ . invoke ( & ctx, & mut stdout)
986
+ . await
987
+ . unwrap ( ) ;
988
+
989
+ if let OutputKind :: Text ( text) = output. output {
990
+ assert_eq ! ( text. chars( ) . count( ) , 3 , "Should have 3 replacement characters" ) ;
991
+ assert ! ( text. chars( ) . all( |c| c == '�' ) , "Should be all replacement characters" ) ;
992
+ } else {
993
+ panic ! ( "expected text output" ) ;
994
+ }
995
+
996
+ let v = serde_json:: json!( {
997
+ "mode" : "Search" ,
998
+ "path" : invalid_only_file_path,
999
+ "pattern" : "test"
1000
+ } ) ;
1001
+ let output = serde_json:: from_value :: < FsRead > ( v)
1002
+ . unwrap ( )
1003
+ . invoke ( & ctx, & mut stdout)
1004
+ . await
1005
+ . unwrap ( ) ;
1006
+
1007
+ if let OutputKind :: Text ( value) = output. output {
1008
+ let matches: Vec < SearchMatch > = serde_json:: from_str ( & value) . unwrap ( ) ;
1009
+ assert_eq ! (
1010
+ matches. len( ) ,
1011
+ 0 ,
1012
+ "Should find no matches in file with only invalid UTF-8"
1013
+ ) ;
1014
+ } else {
1015
+ panic ! ( "expected Text output" ) ;
1016
+ }
1017
+ }
744
1018
}
0 commit comments