Skip to content

Commit ea73f20

Browse files
Fix/fs read (#264)
1 parent 915b30e commit ea73f20

File tree

2 files changed

+285
-11
lines changed

2 files changed

+285
-11
lines changed

crates/chat-cli/src/cli/chat/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3578,7 +3578,7 @@ impl ChatContext {
35783578
style::SetAttribute(Attribute::Bold),
35793579
style::Print(format!(" ● Completed in {}s", tool_time)),
35803580
style::SetForegroundColor(Color::Reset),
3581-
style::Print("\n"),
3581+
style::Print("\n\n"),
35823582
)?;
35833583

35843584
tool_telemetry = tool_telemetry.and_modify(|ev| ev.is_success = Some(true));

crates/chat-cli/src/cli/chat/tools/fs_read.rs

Lines changed: 284 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use crossterm::queue;
66
use crossterm::style::{
77
self,
88
Color,
9+
Stylize,
910
};
1011
use eyre::{
1112
Result,
@@ -28,13 +29,17 @@ use super::{
2829
format_path,
2930
sanitize_path_tool_arg,
3031
};
32+
use crate::cli::chat::CONTINUATION_LINE;
3133
use crate::cli::chat::util::images::{
3234
handle_images_from_paths,
3335
is_supported_image_type,
3436
pre_process,
3537
};
3638
use crate::platform::Context;
3739

40+
const CHECKMARK: &str = "✔";
41+
const CROSS: &str = "✘";
42+
3843
#[derive(Debug, Clone, Deserialize)]
3944
#[serde(tag = "mode")]
4045
pub enum FsRead {
@@ -145,7 +150,9 @@ impl FsLine {
145150

146151
pub async fn queue_description(&self, ctx: &Context, updates: &mut impl Write) -> Result<()> {
147152
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();
149156
queue!(
150157
updates,
151158
style::Print("Reading file: "),
@@ -184,8 +191,9 @@ impl FsLine {
184191
pub async fn invoke(&self, ctx: &Context, _updates: &mut impl Write) -> Result<InvokeOutput> {
185192
let path = sanitize_path_tool_arg(ctx, &self.path);
186193
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();
189197
let (start, end) = (
190198
convert_negative_index(line_count, self.start_line()),
191199
convert_negative_index(line_count, self.end_line()),
@@ -204,7 +212,7 @@ impl FsLine {
204212
}
205213

206214
// The range should be inclusive on both ends.
207-
let file_contents = file
215+
let file_contents = file_content
208216
.lines()
209217
.skip(start)
210218
.take(end - start + 1)
@@ -272,16 +280,17 @@ impl FsSearch {
272280
style::SetForegroundColor(Color::Green),
273281
style::Print(&self.pattern.to_lowercase()),
274282
style::ResetColor,
283+
style::Print("\n"),
275284
)?;
276285
Ok(())
277286
}
278287

279288
pub async fn invoke(&self, ctx: &Context, updates: &mut impl Write) -> Result<InvokeOutput> {
280289
let file_path = sanitize_path_tool_arg(ctx, &self.path);
281290
let pattern = &self.pattern;
282-
let relative_path = format_path(ctx.env().current_dir()?, &file_path);
283291

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);
285294
let lines: Vec<&str> = LinesWithEndings::from(&file_content).collect();
286295

287296
let mut results = Vec::new();
@@ -311,16 +320,35 @@ impl FsSearch {
311320
});
312321
}
313322
}
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+
};
314340

315341
queue!(
316342
updates,
317343
style::SetForegroundColor(Color::Yellow),
318344
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),
323346
style::Print("\n"),
347+
style::Print(" "),
348+
style::Print(result),
349+
style::Print(" Found: "),
350+
style::SetForegroundColor(color),
351+
style::Print(match_text),
324352
style::ResetColor,
325353
)?;
326354

@@ -741,4 +769,250 @@ mod tests {
741769
)
742770
);
743771
}
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"\nGoodbye 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"\nLine 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+
}
7441018
}

0 commit comments

Comments
 (0)