Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "galaxy_files"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Warp Team <dev@warp.dev>"]
|
||||
publish.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
async-channel.workspace = true
|
||||
async-fs.workspace = true
|
||||
futures.workspace = true
|
||||
thiserror.workspace = true
|
||||
galaxyui.workspace = true
|
||||
remote_server.workspace = true
|
||||
galaxy_core.workspace = true
|
||||
galaxy_util.workspace = true
|
||||
watcher.workspace = true
|
||||
notify-debouncer-full.workspace = true
|
||||
repo_metadata.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[features]
|
||||
test-util = []
|
||||
|
||||
[dev-dependencies]
|
||||
async-channel.workspace = true
|
||||
tempfile.workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,254 @@
|
||||
use async_channel::{unbounded, Receiver};
|
||||
use galaxyui::{r#async::block_on, App, ModelHandle};
|
||||
|
||||
// lib_tests.rs
|
||||
use super::*;
|
||||
|
||||
const WRITE_TEST_PATH: &str = "test_data/test_write/";
|
||||
|
||||
/// This enum is used so that we can pass the event through the async channel.
|
||||
/// io::Error is not clonable, so we can't clone the FileModelEvent.
|
||||
#[derive(Debug)]
|
||||
enum TestFileModelEvent {
|
||||
FileLoaded {
|
||||
id: FileId,
|
||||
content: String,
|
||||
_version: ContentVersion,
|
||||
},
|
||||
FileSaved,
|
||||
FailedToLoad(String),
|
||||
FailedToSave,
|
||||
}
|
||||
|
||||
impl From<&FileModelEvent> for TestFileModelEvent {
|
||||
fn from(event: &FileModelEvent) -> Self {
|
||||
match event {
|
||||
FileModelEvent::FileLoaded {
|
||||
id,
|
||||
content,
|
||||
version,
|
||||
} => TestFileModelEvent::FileLoaded {
|
||||
id: *id,
|
||||
content: content.clone(),
|
||||
_version: *version,
|
||||
},
|
||||
FileModelEvent::FileSaved { .. } => TestFileModelEvent::FileSaved,
|
||||
FileModelEvent::FailedToLoad {
|
||||
id: _id,
|
||||
error: err,
|
||||
} => TestFileModelEvent::FailedToLoad(format!("{err:?}")),
|
||||
FileModelEvent::FailedToSave { .. } => TestFileModelEvent::FailedToSave,
|
||||
FileModelEvent::FileUpdated { .. } => {
|
||||
// For now, we don't handle file updated events in tests
|
||||
// This could be extended to include a FileUpdated variant in TestFileModelEvent if needed
|
||||
TestFileModelEvent::FileLoaded {
|
||||
id: event.file_id(),
|
||||
content: String::new(),
|
||||
_version: ContentVersion::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Setup a Tokio channel that will forward any events from the FileModel to the receiver.
|
||||
fn setup_event_channel(
|
||||
app: &mut App,
|
||||
files: &ModelHandle<FileModel>,
|
||||
) -> Receiver<TestFileModelEvent> {
|
||||
let (sender, receiver) = unbounded();
|
||||
app.update(|ctx| {
|
||||
ctx.subscribe_to_model(files, move |_model, event, _ctx| {
|
||||
block_on(sender.send(TestFileModelEvent::from(event)))
|
||||
.expect("Could not send the result");
|
||||
});
|
||||
});
|
||||
receiver
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
let files = app.add_singleton_model(FileModel::new);
|
||||
let receiver = setup_event_channel(app, &files);
|
||||
|
||||
// Load the test file.
|
||||
files.update(app, |model, ctx| {
|
||||
model.open(Path::new("test_data/test_file.rs"), false, ctx);
|
||||
});
|
||||
|
||||
// Check that the first event out is the file loaded event.
|
||||
let event = receiver.recv().await.expect("Could not receive the result");
|
||||
match event {
|
||||
TestFileModelEvent::FileLoaded { content, .. } => {
|
||||
assert_eq!(content.as_bytes(), TEST_FILE_CONTENT)
|
||||
}
|
||||
_ => panic!("Failed to load file"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_uninitialized_file() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
|
||||
let files = app.add_singleton_model(FileModel::new);
|
||||
let id = FileId::new();
|
||||
|
||||
// This file has not been initialized with the model. Make sure trying to save it fails immediately.
|
||||
files.update(app, |model, ctx| {
|
||||
let result = model.save(
|
||||
id,
|
||||
"This file doesn't exist".to_string(),
|
||||
ContentVersion::new(),
|
||||
ctx,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
|
||||
let e = result.unwrap_err();
|
||||
assert!(matches!(e, FileSaveError::NoFilePath(file_id) if file_id == id));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_file() {
|
||||
// Create the test write directory if it doesn't exist.
|
||||
std::fs::create_dir_all(WRITE_TEST_PATH).unwrap();
|
||||
|
||||
// Write the test file content to a random file in the test write directory.
|
||||
let path = PathBuf::from(WRITE_TEST_PATH).join("test_save_file.rs");
|
||||
std::fs::write(&path, TEST_FILE_CONTENT).unwrap();
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
let files = app.add_singleton_model(FileModel::new);
|
||||
let receiver = setup_event_channel(app, &files);
|
||||
|
||||
// Open the newly created file.
|
||||
let path_clone = path.clone();
|
||||
files.update(app, |model, ctx| {
|
||||
model.open(&path_clone, false, ctx);
|
||||
});
|
||||
|
||||
let file_id = match receiver.recv().await.expect("Could not receive the result") {
|
||||
TestFileModelEvent::FileLoaded { id, .. } => id,
|
||||
_ => panic!("Failed to load file"),
|
||||
};
|
||||
|
||||
let old_version = files.read(app, |files, _ctx| files.version(file_id));
|
||||
let new_version = ContentVersion::new();
|
||||
|
||||
// Save new content to the file.
|
||||
files.update(app, |model, ctx| {
|
||||
let result = model.save(file_id, "Overwrite content".to_string(), new_version, ctx);
|
||||
assert!(result.is_ok());
|
||||
});
|
||||
|
||||
// Make sure that the file saved event was emitted.
|
||||
match receiver.recv().await.expect("Could not receive the result") {
|
||||
TestFileModelEvent::FileSaved => (),
|
||||
_ => panic!("Failed to save file"),
|
||||
}
|
||||
|
||||
// Make sure the content on disk matches the content we saved.
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(content, "Overwrite content");
|
||||
|
||||
// Make sure the version was updated.
|
||||
let model_version = files.read(app, |files, _ctx| files.version(file_id));
|
||||
assert_ne!(old_version, model_version);
|
||||
assert_eq!(Some(new_version), model_version);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_missing_file() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
let files = app.add_singleton_model(FileModel::new);
|
||||
let receiver = setup_event_channel(app, &files);
|
||||
|
||||
// Load a file that doesn't exist.
|
||||
files.update(app, |model, ctx| {
|
||||
model.open(Path::new("test_data/missing_file.rs"), false, ctx);
|
||||
});
|
||||
|
||||
// Check that the first event out is the failed to load event.
|
||||
let event = receiver.recv().await.expect("Could not receive the result");
|
||||
match event {
|
||||
TestFileModelEvent::FailedToLoad(err) => {
|
||||
// File not found error strings differ across operating systems.
|
||||
#[cfg(not(windows))]
|
||||
let os_error_message = "No such file or directory";
|
||||
#[cfg(windows)]
|
||||
let os_error_message = "The system cannot find the file specified.";
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
format!(
|
||||
"IOError(Os {{ code: 2, kind: NotFound, message: \"{os_error_message}\" }})"
|
||||
)
|
||||
);
|
||||
}
|
||||
_ => panic!("Failed to load file"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_missing_directory() {
|
||||
// Create the test write directory if it doesn't exist.
|
||||
let directory = PathBuf::from(WRITE_TEST_PATH).join("missing-directory");
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
|
||||
// Write the test file content to a random file in the test write directory.
|
||||
let path = directory.join("test_save_missing_directory.rs");
|
||||
std::fs::write(&path, TEST_FILE_CONTENT).unwrap();
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
let files = app.add_singleton_model(FileModel::new);
|
||||
let receiver = setup_event_channel(app, &files);
|
||||
|
||||
// Save a file to a directory that doesn't exist.
|
||||
let file_id = files.update(app, |model, ctx| model.open(&path, false, ctx));
|
||||
|
||||
// Check that the first event out is the successful load.
|
||||
let event = receiver.recv().await.expect("Could not receive the result");
|
||||
match event {
|
||||
TestFileModelEvent::FileLoaded { content, .. } => {
|
||||
assert_eq!(content.as_bytes(), TEST_FILE_CONTENT)
|
||||
}
|
||||
event => panic!("Failed to load file {event:?}"),
|
||||
}
|
||||
|
||||
// Delete the directory that the file is in.
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
|
||||
// Save new content to the file.
|
||||
files.update(app, |model, ctx| {
|
||||
let result = model.save(
|
||||
file_id,
|
||||
"Overwrite content".to_string(),
|
||||
ContentVersion::new(),
|
||||
ctx,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
});
|
||||
|
||||
// Now we expect the save to succeed because ensure_parent_directories will create the missing directory
|
||||
match receiver.recv().await.expect("Could not receive the result") {
|
||||
TestFileModelEvent::FileSaved => {
|
||||
// Make sure the content on disk matches the content we saved.
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(content, "Overwrite content");
|
||||
}
|
||||
event => panic!("Save should have succeeded but got event: {event:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static TEST_FILE_CONTENT: &[u8] = include_bytes!("../test_data/test_file.rs");
|
||||
@@ -0,0 +1,193 @@
|
||||
use std::ops::Range;
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// A segment of text read from a file, with optional line-range metadata.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TextFileSegment {
|
||||
pub file_name: String,
|
||||
pub content: String,
|
||||
pub line_range: Option<Range<usize>>,
|
||||
pub last_modified: Option<SystemTime>,
|
||||
/// Total number of lines in the source file (not just this segment).
|
||||
pub line_count: usize,
|
||||
}
|
||||
|
||||
/// Result of attempting to read a file as text.
|
||||
pub enum TextFileReadResult {
|
||||
/// Successfully read as text.
|
||||
Segments {
|
||||
segments: Vec<TextFileSegment>,
|
||||
bytes_read: usize,
|
||||
},
|
||||
/// Not valid UTF-8 — caller should try the binary path.
|
||||
NotText,
|
||||
}
|
||||
|
||||
/// Accumulates lines into [`TextFileSegment`]s for a set of (possibly empty)
|
||||
/// line ranges, enforcing a byte budget and tracking the total line count.
|
||||
///
|
||||
/// **Line ending normalization**: `\n` and `\r\n` line endings are normalized
|
||||
/// to `\n` (LF) in the emitted [`TextFileSegment::content`]. Classic Mac
|
||||
/// `\r`-only line endings are **not** recognized as line separators (matching
|
||||
/// the behavior of `read_line()`, which only splits on `\n`). Lines are
|
||||
/// expected to be pushed with their terminators already stripped (as produced
|
||||
/// by `read_line()` + manual stripping). The trailing newline of the file, if
|
||||
/// present, is preserved via the `has_trailing_newline` flag passed to
|
||||
/// [`Self::push_line`].
|
||||
pub(crate) struct TextFileAccumulator {
|
||||
file_name: String,
|
||||
last_modified: Option<SystemTime>,
|
||||
effective_ranges: Vec<Range<usize>>,
|
||||
whole_file: bool,
|
||||
max_bytes: usize,
|
||||
segments: Vec<TextFileSegment>,
|
||||
total_bytes_read: usize,
|
||||
range_idx: usize,
|
||||
buf: Vec<String>,
|
||||
buf_bytes: usize,
|
||||
truncated: bool,
|
||||
last_line: usize,
|
||||
current_line: usize,
|
||||
/// Whether the most recently pushed line had a trailing newline in the
|
||||
/// original file. Updated on every [`Self::push_line`] call so that after
|
||||
/// all lines are pushed, this reflects the final line's terminator state.
|
||||
last_line_had_newline: bool,
|
||||
}
|
||||
|
||||
impl TextFileAccumulator {
|
||||
#[allow(clippy::single_range_in_vec_init)]
|
||||
pub(crate) fn new(
|
||||
file_name: String,
|
||||
last_modified: Option<SystemTime>,
|
||||
requested_ranges: &[Range<usize>],
|
||||
max_bytes: usize,
|
||||
) -> Self {
|
||||
let whole_file = requested_ranges.is_empty();
|
||||
let effective_ranges = if whole_file {
|
||||
vec![1..usize::MAX]
|
||||
} else {
|
||||
let mut sorted = requested_ranges.to_vec();
|
||||
sorted.sort_by_key(|r| r.start);
|
||||
sorted
|
||||
};
|
||||
Self {
|
||||
file_name,
|
||||
last_modified,
|
||||
effective_ranges,
|
||||
whole_file,
|
||||
max_bytes,
|
||||
segments: Vec::new(),
|
||||
total_bytes_read: 0,
|
||||
range_idx: 0,
|
||||
buf: Vec::new(),
|
||||
buf_bytes: 0,
|
||||
truncated: false,
|
||||
last_line: 0,
|
||||
current_line: 0,
|
||||
last_line_had_newline: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes a single line (with its terminator already stripped) into the
|
||||
/// accumulator.
|
||||
///
|
||||
/// `has_trailing_newline` indicates whether this line was terminated by a
|
||||
/// newline in the original file. For every line except possibly the last
|
||||
/// one in a file, this will be `true`.
|
||||
pub(crate) fn push_line(&mut self, line: String, has_trailing_newline: bool) {
|
||||
self.current_line += 1;
|
||||
self.last_line_had_newline = has_trailing_newline;
|
||||
|
||||
if self.range_idx >= self.effective_ranges.len() {
|
||||
// Past all requested ranges — just count remaining lines.
|
||||
return;
|
||||
}
|
||||
|
||||
// Past the current range — finalize it and advance.
|
||||
if self.current_line >= self.effective_ranges[self.range_idx].end {
|
||||
self.flush_range(false);
|
||||
self.range_idx += 1;
|
||||
}
|
||||
|
||||
// Within the current range — accumulate.
|
||||
if self.range_idx < self.effective_ranges.len() {
|
||||
let range = &self.effective_ranges[self.range_idx];
|
||||
if self.current_line >= range.start && self.current_line < range.end && !self.truncated
|
||||
{
|
||||
let line_bytes = line.len() + if self.buf.is_empty() { 0 } else { 1 };
|
||||
if self.total_bytes_read + self.buf_bytes + line_bytes > self.max_bytes {
|
||||
self.truncated = true;
|
||||
} else {
|
||||
self.buf_bytes += line_bytes;
|
||||
self.last_line = self.current_line;
|
||||
self.buf.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a [`TextFileSegment`] for the current range (if non-empty, or if
|
||||
/// this is the final flush of a whole-file read) and resets per-range state.
|
||||
fn flush_range(&mut self, final_flush: bool) {
|
||||
let should_emit = !self.buf.is_empty() || (final_flush && self.whole_file);
|
||||
|
||||
if should_emit {
|
||||
let range = self.effective_ranges[self.range_idx].clone();
|
||||
let line_range = if self.whole_file && !self.truncated {
|
||||
None
|
||||
} else if self.truncated {
|
||||
Some(range.start..self.last_line)
|
||||
} else {
|
||||
Some(range)
|
||||
};
|
||||
|
||||
self.total_bytes_read += self.buf_bytes;
|
||||
let mut content = std::mem::take(&mut self.buf).join("\n");
|
||||
|
||||
// If this is the final flush of a non-truncated whole-file read
|
||||
// and the last line in the file had a trailing newline, preserve
|
||||
// it. This ensures round-tripping file content through the
|
||||
// accumulator doesn't silently drop a trailing newline (which
|
||||
// would otherwise cause data loss when the content is written
|
||||
// back to disk, e.g. during remote diff application).
|
||||
//
|
||||
// We skip this for truncated reads (the content is incomplete, so
|
||||
// appending a newline would be misleading) and for ranged reads
|
||||
// (which extract a slice, not the full file).
|
||||
if final_flush && self.whole_file && !self.truncated && self.last_line_had_newline {
|
||||
content.push('\n');
|
||||
self.total_bytes_read += 1;
|
||||
}
|
||||
|
||||
self.segments.push(TextFileSegment {
|
||||
file_name: self.file_name.clone(),
|
||||
content,
|
||||
line_range,
|
||||
last_modified: self.last_modified,
|
||||
line_count: 0, // Set in finalize()
|
||||
});
|
||||
}
|
||||
|
||||
self.buf.clear();
|
||||
self.buf_bytes = 0;
|
||||
self.truncated = false;
|
||||
self.last_line = 0;
|
||||
}
|
||||
|
||||
pub(crate) fn finalize(mut self) -> (Vec<TextFileSegment>, usize) {
|
||||
if self.range_idx < self.effective_ranges.len() {
|
||||
self.flush_range(true);
|
||||
}
|
||||
|
||||
let total_line_count = self.current_line;
|
||||
for segment in &mut self.segments {
|
||||
segment.line_count = total_line_count;
|
||||
}
|
||||
|
||||
(self.segments, self.total_bytes_read)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "text_file_reader_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,321 @@
|
||||
#![allow(clippy::single_range_in_vec_init)]
|
||||
|
||||
use std::io::Write as _;
|
||||
|
||||
use crate::FileModel;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn make_accumulator(ranges: &[std::ops::Range<usize>], max_bytes: usize) -> TextFileAccumulator {
|
||||
TextFileAccumulator::new("test.txt".to_string(), None, ranges, max_bytes)
|
||||
}
|
||||
|
||||
/// Helper: push a line that was terminated by a newline in the original file
|
||||
/// (i.e. every line except possibly the very last one).
|
||||
fn push(acc: &mut TextFileAccumulator, line: &str) {
|
||||
acc.push_line(line.to_string(), true);
|
||||
}
|
||||
|
||||
/// Helper: push the final line of a file that had **no** trailing newline.
|
||||
fn push_no_newline(acc: &mut TextFileAccumulator, line: &str) {
|
||||
acc.push_line(line.to_string(), false);
|
||||
}
|
||||
|
||||
// ── Whole-file (no ranges) ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn whole_file_reads_all_lines() {
|
||||
let mut acc = make_accumulator(&[], 1000);
|
||||
push(&mut acc, "hello");
|
||||
push(&mut acc, "world");
|
||||
let (segments, bytes_read) = acc.finalize();
|
||||
|
||||
assert_eq!(segments.len(), 1);
|
||||
// File had trailing newline → preserved.
|
||||
assert_eq!(segments[0].content, "hello\nworld\n");
|
||||
assert_eq!(segments[0].line_range, None);
|
||||
assert_eq!(segments[0].line_count, 2);
|
||||
assert_eq!(bytes_read, 12); // "hello" + "\n" + "world" + "\n"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_file_truncated_at_byte_limit() {
|
||||
let mut acc = make_accumulator(&[], 8);
|
||||
push(&mut acc, "hello"); // 5 bytes
|
||||
push(&mut acc, "world"); // +1 sep +5 = 11 > 8 → truncated
|
||||
push(&mut acc, "extra");
|
||||
let (segments, bytes_read) = acc.finalize();
|
||||
|
||||
assert_eq!(segments.len(), 1);
|
||||
assert_eq!(segments[0].content, "hello");
|
||||
assert_eq!(segments[0].line_range, Some(1..1)); // truncated → range shown
|
||||
assert_eq!(segments[0].line_count, 3);
|
||||
assert_eq!(bytes_read, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_file_whole_file_produces_segment() {
|
||||
let acc = make_accumulator(&[], 1000);
|
||||
let (segments, bytes_read) = acc.finalize();
|
||||
|
||||
assert_eq!(segments.len(), 1);
|
||||
assert_eq!(segments[0].content, "");
|
||||
assert_eq!(segments[0].line_range, None);
|
||||
assert_eq!(segments[0].line_count, 0);
|
||||
assert_eq!(bytes_read, 0);
|
||||
}
|
||||
|
||||
// ── Line ranges ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn single_range_extracted() {
|
||||
let mut acc = make_accumulator(&[2..4], 1000);
|
||||
for i in 1..=5 {
|
||||
push(&mut acc, &format!("line{i}"));
|
||||
}
|
||||
let (segments, bytes_read) = acc.finalize();
|
||||
|
||||
assert_eq!(segments.len(), 1);
|
||||
assert_eq!(segments[0].content, "line2\nline3");
|
||||
assert_eq!(segments[0].line_range, Some(2..4));
|
||||
assert_eq!(segments[0].line_count, 5);
|
||||
assert_eq!(bytes_read, 11); // "line2" + "\n" + "line3"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_ranges_produce_separate_segments() {
|
||||
let mut acc = make_accumulator(&[1..2, 4..6], 1000);
|
||||
for i in 1..=6 {
|
||||
push(&mut acc, &format!("L{i}"));
|
||||
}
|
||||
let (segments, bytes_read) = acc.finalize();
|
||||
|
||||
assert_eq!(segments.len(), 2);
|
||||
assert_eq!(segments[0].content, "L1");
|
||||
assert_eq!(segments[0].line_range, Some(1..2));
|
||||
assert_eq!(segments[1].content, "L4\nL5");
|
||||
assert_eq!(segments[1].line_range, Some(4..6));
|
||||
for seg in &segments {
|
||||
assert_eq!(seg.line_count, 6);
|
||||
}
|
||||
assert_eq!(bytes_read, 7); // "L1" (2) + "L4\nL5" (5)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsorted_ranges_are_sorted() {
|
||||
let mut acc = make_accumulator(&[4..6, 1..3], 1000);
|
||||
for i in 1..=6 {
|
||||
push(&mut acc, &format!("L{i}"));
|
||||
}
|
||||
let (segments, _) = acc.finalize();
|
||||
|
||||
assert_eq!(segments.len(), 2);
|
||||
// Should come out sorted by start line.
|
||||
assert_eq!(segments[0].line_range, Some(1..3));
|
||||
assert_eq!(segments[0].content, "L1\nL2");
|
||||
assert_eq!(segments[1].line_range, Some(4..6));
|
||||
assert_eq!(segments[1].content, "L4\nL5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_file_with_ranges_produces_no_segment() {
|
||||
let acc = make_accumulator(&[1..5], 1000);
|
||||
let (segments, bytes_read) = acc.finalize();
|
||||
|
||||
assert_eq!(segments.len(), 0);
|
||||
assert_eq!(bytes_read, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_count_reflects_total_file_lines() {
|
||||
let mut acc = make_accumulator(&[2..4], 1000);
|
||||
for i in 1..=10 {
|
||||
push(&mut acc, &format!("line{i}"));
|
||||
}
|
||||
let (segments, _) = acc.finalize();
|
||||
|
||||
assert_eq!(segments[0].line_count, 10);
|
||||
}
|
||||
|
||||
// ── Truncation with ranges ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn range_truncated_at_byte_limit() {
|
||||
let mut acc = make_accumulator(&[2..6], 8);
|
||||
push(&mut acc, "skip"); // line 1, outside range
|
||||
push(&mut acc, "aaaa"); // line 2, 4 bytes
|
||||
push(&mut acc, "bbbb"); // line 3, +1+4 = 9 > 8 → truncated
|
||||
push(&mut acc, "cccc");
|
||||
push(&mut acc, "dddd");
|
||||
let (segments, bytes_read) = acc.finalize();
|
||||
|
||||
assert_eq!(segments.len(), 1);
|
||||
assert_eq!(segments[0].content, "aaaa");
|
||||
assert_eq!(segments[0].line_range, Some(2..2));
|
||||
assert_eq!(segments[0].line_count, 5);
|
||||
assert_eq!(bytes_read, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_budget_shared_across_ranges() {
|
||||
// Budget of 12 bytes. First range uses 5, leaving 7 for the second.
|
||||
let mut acc = make_accumulator(&[1..2, 3..5], 12);
|
||||
push(&mut acc, "hello"); // range 1: 5 bytes, total 5
|
||||
push(&mut acc, "gap"); // not in any range
|
||||
push(&mut acc, "world"); // range 2: 5 bytes, total 10 ≤ 12
|
||||
push(&mut acc, "extra"); // range 2: +1+5 = 16 > 12 → truncated
|
||||
let (segments, bytes_read) = acc.finalize();
|
||||
|
||||
assert_eq!(segments.len(), 2);
|
||||
assert_eq!(segments[0].content, "hello");
|
||||
assert_eq!(segments[0].line_range, Some(1..2));
|
||||
assert_eq!(segments[1].content, "world");
|
||||
assert_eq!(segments[1].line_range, Some(3..3)); // truncated at line 3
|
||||
assert_eq!(bytes_read, 10);
|
||||
}
|
||||
|
||||
// ── Trailing newline preservation ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn whole_file_with_trailing_newline() {
|
||||
// Simulates a file "hello\nworld\n" — both lines terminated.
|
||||
let mut acc = make_accumulator(&[], 1000);
|
||||
push(&mut acc, "hello");
|
||||
push(&mut acc, "world");
|
||||
let (segments, _) = acc.finalize();
|
||||
|
||||
assert_eq!(segments[0].content, "hello\nworld\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_file_without_trailing_newline() {
|
||||
// Simulates a file "hello\nworld" — last line has no terminator.
|
||||
let mut acc = make_accumulator(&[], 1000);
|
||||
push(&mut acc, "hello");
|
||||
push_no_newline(&mut acc, "world");
|
||||
let (segments, _) = acc.finalize();
|
||||
|
||||
assert_eq!(segments[0].content, "hello\nworld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_line_file_with_trailing_newline() {
|
||||
// Simulates a file "hello\n".
|
||||
let mut acc = make_accumulator(&[], 1000);
|
||||
push(&mut acc, "hello");
|
||||
let (segments, _) = acc.finalize();
|
||||
|
||||
assert_eq!(segments[0].content, "hello\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_line_file_without_trailing_newline() {
|
||||
// Simulates a file "hello" (no terminator).
|
||||
let mut acc = make_accumulator(&[], 1000);
|
||||
push_no_newline(&mut acc, "hello");
|
||||
let (segments, _) = acc.finalize();
|
||||
|
||||
assert_eq!(segments[0].content, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newline_only_file_round_trips() {
|
||||
// A file consisting of exactly "\n" should round-trip correctly.
|
||||
// read_line() yields a single empty line with has_newline=true.
|
||||
let mut acc = make_accumulator(&[], 1000);
|
||||
push(&mut acc, "");
|
||||
let (segments, _) = acc.finalize();
|
||||
|
||||
assert_eq!(segments[0].content, "\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranged_read_does_not_append_trailing_newline() {
|
||||
// Trailing newline preservation only applies to whole-file reads.
|
||||
// Ranged reads should not add a trailing newline.
|
||||
let mut acc = make_accumulator(&[1..3], 1000);
|
||||
push(&mut acc, "line1");
|
||||
push(&mut acc, "line2");
|
||||
push(&mut acc, "line3");
|
||||
let (segments, _) = acc.finalize();
|
||||
|
||||
assert_eq!(segments[0].content, "line1\nline2");
|
||||
}
|
||||
|
||||
// ── FileModel::read_text_file (async, real file) ───────────────
|
||||
|
||||
#[test]
|
||||
fn non_utf8_file_returns_not_text() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("binary.bin");
|
||||
{
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
f.write_all(b"valid line\n").unwrap();
|
||||
f.write_all(&[0xFF, 0xFE, 0x80, 0x81]).unwrap();
|
||||
f.write_all(b"\nanother line\n").unwrap();
|
||||
}
|
||||
|
||||
let result =
|
||||
futures::executor::block_on(FileModel::read_text_file(&path, 10_000, &[], None)).unwrap();
|
||||
assert!(matches!(result, TextFileReadResult::NotText));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_text_file_preserves_trailing_newline() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("trailing.txt");
|
||||
std::fs::write(&path, "hello\nworld\n").unwrap();
|
||||
|
||||
let result =
|
||||
futures::executor::block_on(FileModel::read_text_file(&path, 10_000, &[], None)).unwrap();
|
||||
let TextFileReadResult::Segments { segments, .. } = result else {
|
||||
panic!("expected Segments");
|
||||
};
|
||||
assert_eq!(segments[0].content, "hello\nworld\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_text_file_no_trailing_newline() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("no_trailing.txt");
|
||||
std::fs::write(&path, "hello\nworld").unwrap();
|
||||
|
||||
let result =
|
||||
futures::executor::block_on(FileModel::read_text_file(&path, 10_000, &[], None)).unwrap();
|
||||
let TextFileReadResult::Segments { segments, .. } = result else {
|
||||
panic!("expected Segments");
|
||||
};
|
||||
assert_eq!(segments[0].content, "hello\nworld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_text_file_crlf_normalized_to_lf() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("crlf.txt");
|
||||
std::fs::write(&path, "hello\r\nworld\r\n").unwrap();
|
||||
|
||||
let result =
|
||||
futures::executor::block_on(FileModel::read_text_file(&path, 10_000, &[], None)).unwrap();
|
||||
let TextFileReadResult::Segments { segments, .. } = result else {
|
||||
panic!("expected Segments");
|
||||
};
|
||||
// CRLF is normalized to LF; trailing newline preserved.
|
||||
assert_eq!(segments[0].content, "hello\nworld\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_text_file_round_trip_fidelity() {
|
||||
// Verifies that reading a file and writing the content back produces
|
||||
// an identical file — the original motivation for this fix.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let original = "fn main() {\n println!(\"hello\");\n}\n";
|
||||
let path = dir.path().join("roundtrip.rs");
|
||||
std::fs::write(&path, original).unwrap();
|
||||
|
||||
let result =
|
||||
futures::executor::block_on(FileModel::read_text_file(&path, 10_000, &[], None)).unwrap();
|
||||
let TextFileReadResult::Segments { segments, .. } = result else {
|
||||
panic!("expected Segments");
|
||||
};
|
||||
assert_eq!(segments[0].content, original);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use std::fs::File;
|
||||
use std::io::prelude::*;
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
// Create a path to the desired file
|
||||
let path = Path::new("hello.txt");
|
||||
let display = path.display();
|
||||
|
||||
// Open the path in read-only mode, returns `io::Result<File>`
|
||||
let mut file = match File::open(&path) {
|
||||
Err(why) => panic!("couldn't open {}: {}", display, why),
|
||||
Ok(file) => file,
|
||||
};
|
||||
|
||||
// Read the file contents into a string, returns `io::Result<usize>`
|
||||
let mut s = String::new();
|
||||
match file.read_to_string(&mut s) {
|
||||
Err(why) => panic!("couldn't read {}: {}", display, why),
|
||||
Ok(_) => print!("{} contains:\n{}", display, s),
|
||||
}
|
||||
|
||||
// `file` goes out of scope, and the "hello.txt" file gets closed
|
||||
}
|
||||
Reference in New Issue
Block a user