19 Commits

Author SHA1 Message Date
dailz
dfc016c348 fix(tui): wrap color backward cycle at index 0 instead of clamping (closes #29) 2026-06-11 15:05:42 +08:00
dailz
19a3b877f9 fix(core): tighten word boundary to reject digits and underscores in level detection (closes #28) 2026-06-11 14:28:13 +08:00
dailz
5cb56dafd8 fix(core): correct tab-stop alignment and width overflow in wrap_line_chars
- Extract TAB_WIDTH constant (4) replacing magic numbers
- Calculate tab stop as TAB_WIDTH - (col % TAB_WIDTH) for proper alignment
- Split tab expansion across rows when width < TAB_WIDTH
- Update test_wrap_with_tab expected value for new behavior
- Add tests: narrow width, stop alignment, line boundary, regression

Fixes: #27
2026-06-11 13:37:14 +08:00
dailz
e99861c76d fix(tui): add MAX_WRAP_INPUT_LEN guard to prevent UI freeze on oversized lines (closes #26)
- compute_line_entry/compute_visual_height: double guard (raw + post-format)
- Skip detect_level on oversized raw input to avoid O(n) JSON parsing
- Add post-format guard for JSON lines that expand beyond 10MB
- progressive_reader: add post-format guard to compute_line_visual_height
- Add truncate_to_columns helper using existing wrap_line_chars
- Fix misleading docstring on MAX_WRAP_INPUT_LEN constant
- Add 6 regression tests covering all guard paths
2026-06-11 13:15:11 +08:00
dailz
a43ef673b0 fix(tui): rebase v_offset before VHI invalidation to prevent viewport jump (closes #25)
Ready state can have VHI=None during async rebuild (Tab toggle, resize,
file append, Loading→Ready transition). Without rebase, v_offset retains
a visual-row value that gets treated as a logical-line offset, causing
viewport jumps and cursor drift.

Changes:
- Add rebase_offset_for_invalidate() to convert v_offset from visual-row
  to logical-line + sub-row before VHI invalidation
- Call it at all 6 invalidation sites in Ready state
- Recalibrate v_offset from viewport top when VHI rebuild completes
- Clamp preserved sub_row to new line height after JSON/width changes

Regression tests: 6 new tests covering rebase conversion, Tab toggle,
VHI rebuild recalibration, sub-row clamping, and Loading→Ready transition.
2026-06-11 09:49:10 +08:00
dailz
70f930eef7 fix(tui): use updated v_offset after params_changed in ensure_viewport_cache (#24)
The loading branch of ensure_viewport_cache captured v_offset before the
params_changed block, which could reassign self.v_offset. This caused the
viewport to use a stale offset when loading + width/format changed together.

Remove the stale local variable and read self.v_offset directly, consistent
with the non-loading branch. Add regression test.
2026-06-11 08:58:10 +08:00
dailz
463c53148b fix(tui): filter KeyEventKind to prevent Release/Repeat from triggering commands (closes #23) 2026-06-10 17:34:17 +08:00
dailz
e9f75ce3b1 fix(parser): detect duplicate JSON keys via custom Visitor instead of silent last-wins (closes #22) 2026-06-10 15:22:55 +08:00
dailz
ef1889767a fix(parser): trim whitespace in LogLevel::from_str to prevent misclassification (closes #21)
Before this fix, level strings with surrounding whitespace (e.g. " WARN ")
were incorrectly parsed as Unknown instead of the matching variant.
This primarily affected the JSON log path where serde preserves the raw
field value including whitespace.

Changes:
- Add s.trim() before case-insensitive matching in FromStr impl
- Store trimmed value in Unknown variant to avoid whitespace noise
- Add unit tests for whitespace-padded known levels
- Add unit tests for Unknown trimming semantics (empty, internal ws)
- Add JSON regression test for level field with surrounding whitespace
2026-06-10 13:37:11 +08:00
dailz
eedab3ac96 fix(parser): strip UTF-8 BOM before JSON parsing
serde_json rejects BOM (U+FEFF) prefixed input with 'expected value'
error, causing BOM-prefixed JSON log lines to be silently dropped.

Add strip_bom() helper that strips exactly one leading BOM character,
apply it in detect_json_log() and parse_line().

Closes #20
2026-06-10 11:37:00 +08:00
dailz
8e9600dda2 fix(parser): preserve non-string timestamp/level fields instead of silently dropping them (closes #19)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-10 10:47:04 +08:00
dailz
2cebbd94c4 fix: concurrent cache save uses unique temp files (#17)
Replace deterministic .index.tmp path with per-save unique temp files
via tempfile::Builder. Eliminates race condition where background
indexer thread and TUI main thread could collide on the same temp path,
causing data truncation or corrupt cache writes.

Changes:
- Add write_cache_atomically() helper using tempfile::Builder
- Refactor save_with_hash() and save() to use the helper
- Extract encode_cache() to deduplicate serialization logic
- Move tempfile from [dev-dependencies] to [dependencies]
- Add 2 concurrent tests validating no corruption under parallel writes

Fixes #17
2026-06-09 16:13:39 +08:00
dailz
0d88e933e6 fix(io): replace blocking channel sends with cancel-aware alternatives (closes #16)
Background worker threads used blocking tx.send() on bounded channels.
If the consumer stopped draining, threads hung forever with no way to
reach the cancel check. Drop-issued cancellation was ineffective.

Changes:
- Progress messages: tx.try_send() (discard if full, never blocks loop)
- Terminal messages (Complete/Error): new send_cancelable<T>() helper
  using crossbeam select! — sleeps efficiently until send succeeds or
  cancel arrives
- Drop cancellation: tx.try_send() — Drop must never block
- spawn_visual_height_rebuild: same fix for its bounded(1) channel
- 5 new tests covering full-channel + cancel scenarios
2026-06-09 15:14:37 +08:00
dailz
420b853cb9 fix(watcher): filter Remove events by path to prevent false removed reports (closes #15) 2026-06-09 13:18:23 +08:00
dailz
7852e92ecc fix(watcher): forward notify backend errors instead of silently discarding
Previously Err(_) => return in the notify callback silently dropped all
backend errors (inotify exhaustion, fs unmount, permission loss), leaving
the application unaware that file monitoring had stopped working.

Add FileEvent::WatcherError { message: String } variant to propagate
backend errors through the existing bounded channel. The TUI consumer
receives the event without disrupting the UI for transient errors.

Closes #14
2026-06-09 11:26:54 +08:00
dailz
d37ed6df68 fix(io): harden read_cache against zero-length false hits and overflow (closes #13)
- Add early return for len==0 (Ok(&[])) matching std::io semantics
- Add slot.len > 0 guard to cache hit predicate to prevent empty-slot
  false matches
- Replace unchecked arithmetic with checked_add/saturating_add for
  request_end, block_end, and post-read coverage check
- Fix misleading comment about get(file,0,0) behavior on miss path
- Strengthen clear() to fully reset block_offset and last_access
- Register read_cache module in io/mod.rs
- Add 4 regression tests: zero-len on fresh/populated cache,
  zero-len at u64::MAX, overflow error on nonzero read at u64::MAX
2026-06-09 10:48:34 +08:00
dailz
b58d66f2aa fix(io): use unicode-width for correct CJK/emoji/zero-width display width (closes #12) 2026-06-07 12:50:17 +08:00
dailz
d4679a7543 fix(io): update visual height of last line on append without trailing newline (closes #11)
When a file does not end with a newline, appending content extends the
last logical line's text and thus its visual height. The incremental
extend path in handle_file_appended only computed heights for newly
created logical lines, missing the old last line whose content changed.

Add VisualHeightIndex::replace_last_line_height() — an O(1) method that
rewrites the final prefix sum entry and total. Called before
extend_from_heights so the correct line is targeted.

Changes:
- progressive_reader.rs: add replace_last_line_height, pub with_params,
  7 VHI unit tests
- app.rs: save old_reader_line_count before update, recompute last old
  line height in extend path, 2 integration regression tests
2026-06-07 09:46:24 +08:00
dailz
8844e58cb4 Merge fix/m20-append-lines-error-handling: fix append_lines I/O error swallowing (closes #45) + clippy cleanup 2026-06-07 09:17:41 +08:00
13 changed files with 1807 additions and 217 deletions

1
Cargo.lock generated
View File

@@ -2333,6 +2333,7 @@ dependencies = [
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"toml", "toml",
"unicode-width",
"xxhash-rust", "xxhash-rust",
] ]

View File

@@ -27,3 +27,4 @@ textwrap = "0.16"
tempfile = "3" tempfile = "3"
xxhash-rust = { version = "0.8", features = ["xxh3"] } xxhash-rust = { version = "0.8", features = ["xxh3"] }
bincode = "1" bincode = "1"
unicode-width = "0.2"

View File

@@ -17,7 +17,8 @@ memmap2.workspace = true
directories.workspace = true directories.workspace = true
xxhash-rust.workspace = true xxhash-rust.workspace = true
bincode.workspace = true bincode.workspace = true
unicode-width.workspace = true
tempfile.workspace = true
[dev-dependencies] [dev-dependencies]
insta.workspace = true insta.workspace = true
tempfile.workspace = true

View File

@@ -6,8 +6,44 @@ use crate::io::line_index::LineIndex;
pub struct IndexCache; pub struct IndexCache;
/// Write `buf` to `dest` atomically using a unique temporary file in the same directory.
///
/// Each call creates its own temp file via `tempfile::Builder`, eliminating collisions
/// when multiple threads (or processes) save to the same cache path concurrently.
/// The temp file is created in `dest.parent()` so the final `rename` stays on the
/// same filesystem and remains atomic.
fn write_cache_atomically(dest: &Path, buf: &[u8]) -> std::io::Result<()> {
let dir = dest.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"cache destination has no parent directory",
)
})?;
let mut tmp = tempfile::Builder::new()
.prefix("index-cache-")
.suffix(".tmp")
.tempfile_in(dir)?;
tmp.as_file_mut().write_all(buf)?;
tmp.as_file_mut().sync_all()?;
tmp.persist(dest).map(|_| ()).map_err(|e| e.error)
}
fn encode_cache(file_hash: u64, index: &LineIndex) -> std::io::Result<Vec<u8>> {
let index_bytes = bincode::serialize(index)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
let mut buf = Vec::with_capacity(1 + 8 + index_bytes.len());
buf.push(CACHE_VERSION);
buf.extend_from_slice(&file_hash.to_le_bytes());
buf.extend_from_slice(&index_bytes);
Ok(buf)
}
impl IndexCache { impl IndexCache {
/// Save a `LineIndex` to disk using atomic write (write to .tmp, then rename). /// Save a `LineIndex` to disk using atomic write (unique temp file, then rename).
/// ///
/// The file hash is derived from `data` (the same byte slice used to build the index), /// The file hash is derived from `data` (the same byte slice used to build the index),
/// avoiding TOCTOU issues from re-reading the file from disk. /// avoiding TOCTOU issues from re-reading the file from disk.
@@ -21,23 +57,8 @@ impl IndexCache {
})?; })?;
let file_hash = compute_data_hash(data); let file_hash = compute_data_hash(data);
let index_bytes = bincode::serialize(index) let buf = encode_cache(file_hash, index)?;
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?; write_cache_atomically(&dest, &buf)
let mut buf = Vec::with_capacity(1 + 8 + index_bytes.len());
buf.push(CACHE_VERSION);
buf.extend_from_slice(&file_hash.to_le_bytes());
buf.extend_from_slice(&index_bytes);
let tmp_path = dest.with_extension("index.tmp");
{
let mut f = std::fs::File::create(&tmp_path)?;
f.write_all(&buf)?;
f.sync_all()?;
}
std::fs::rename(&tmp_path, &dest)?;
Ok(())
} }
/// Save a `LineIndex` to disk, computing the hash by re-reading the file. /// Save a `LineIndex` to disk, computing the hash by re-reading the file.
@@ -50,23 +71,8 @@ impl IndexCache {
})?; })?;
let file_hash = compute_file_hash(file_path)?; let file_hash = compute_file_hash(file_path)?;
let index_bytes = bincode::serialize(index) let buf = encode_cache(file_hash, index)?;
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?; write_cache_atomically(&dest, &buf)
let mut buf = Vec::with_capacity(1 + 8 + index_bytes.len());
buf.push(CACHE_VERSION);
buf.extend_from_slice(&file_hash.to_le_bytes());
buf.extend_from_slice(&index_bytes);
let tmp_path = dest.with_extension("index.tmp");
{
let mut f = std::fs::File::create(&tmp_path)?;
f.write_all(&buf)?;
f.sync_all()?;
}
std::fs::rename(&tmp_path, &dest)?;
Ok(())
} }
/// Load a cached `LineIndex` from disk. /// Load a cached `LineIndex` from disk.
@@ -305,4 +311,77 @@ mod tests {
assert_ne!(h1, h2, "hash should change when content changes"); assert_ne!(h1, h2, "hash should change when content changes");
} }
#[test]
fn test_concurrent_save_with_hash_no_corruption() {
use std::sync::{Arc, Barrier};
let file = make_test_file(300);
let data = std::fs::read(file.path()).unwrap();
let num_threads = 8;
let iterations = 50;
let barrier = Arc::new(Barrier::new(num_threads));
let path = file.path().to_path_buf();
let handles: Vec<_> = (0..num_threads)
.map(|_| {
let barrier = Arc::clone(&barrier);
let path = path.clone();
let data = data.clone();
std::thread::spawn(move || {
let index = LineIndex::from_bytes(&data);
barrier.wait();
for _ in 0..iterations {
IndexCache::save_with_hash(&path, &index, &data)
.expect("concurrent save_with_hash should succeed");
}
})
})
.collect();
for h in handles {
h.join().expect("thread should not panic");
}
let loaded = IndexCache::load(file.path()).expect("final cache should load successfully");
let expected = LineIndex::from_bytes(&data);
assert_eq!(loaded.line_count(), expected.line_count());
assert_eq!(
loaded.sampled_offsets(),
expected.sampled_offsets(),
"concurrent writes must not produce interleaved data"
);
}
#[test]
fn test_concurrent_save_same_dest_all_succeed() {
use std::sync::{Arc, Barrier};
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("test.index");
let payloads: Vec<Vec<u8>> = (0..8).map(|i| vec![i; 64 * 1024]).collect();
let barrier = Arc::new(Barrier::new(8));
let handles: Vec<_> = payloads
.into_iter()
.map(|payload| {
let barrier = Arc::clone(&barrier);
let dest = dest.clone();
std::thread::spawn(move || {
barrier.wait();
write_cache_atomically(&dest, &payload)
.expect("concurrent atomic write should succeed");
})
})
.collect();
for h in handles {
h.join().expect("thread should not panic");
}
let final_data = std::fs::read(&dest).expect("dest file should exist");
assert_eq!(final_data.len(), 64 * 1024, "final file must be exactly one payload");
}
} }

View File

@@ -18,4 +18,5 @@ pub mod cache_util;
pub mod index_cache; pub mod index_cache;
pub mod line_sampler; pub mod line_sampler;
pub mod progressive_reader; pub mod progressive_reader;
pub mod read_cache;
pub mod wrap; pub mod wrap;

View File

@@ -10,6 +10,24 @@ use crate::io::line_index::LineIndex;
use crate::io::line_sampler::sample_line_count; use crate::io::line_sampler::sample_line_count;
use crate::io::wrap::{format_json_line, wrap_line_chars, MAX_WRAP_INPUT_LEN}; use crate::io::wrap::{format_json_line, wrap_line_chars, MAX_WRAP_INPUT_LEN};
// ─── Cancel-aware channel helpers ────────────────────────────────────────────
/// Send a message on `tx`, but abort if `cancel_rx` fires first.
///
/// Uses `crossbeam_channel::select!` so the thread sleeps efficiently instead
/// of busy-looping. Used for terminal messages (Complete / Error) that must
/// not be silently dropped while the receiver is still alive.
fn send_cancelable<T>(
tx: &crossbeam_channel::Sender<T>,
msg: T,
cancel_rx: &crossbeam_channel::Receiver<()>,
) {
crossbeam_channel::select! {
send(tx, msg) -> _ => {}
recv(cancel_rx) -> _ => {}
}
}
// ─── IndexerMessage ────────────────────────────────────────────────────────── // ─── IndexerMessage ──────────────────────────────────────────────────────────
pub enum IndexerMessage { pub enum IndexerMessage {
@@ -102,7 +120,7 @@ impl VisualHeightIndex {
} }
} }
fn with_params(mut self, json_format: bool, terminal_width: usize) -> Self { pub fn with_params(mut self, json_format: bool, terminal_width: usize) -> Self {
self.json_format = json_format; self.json_format = json_format;
self.terminal_width = terminal_width; self.terminal_width = terminal_width;
self self
@@ -159,6 +177,31 @@ impl VisualHeightIndex {
self.total_visual_rows += h as u64; self.total_visual_rows += h as u64;
} }
} }
/// Replace the visual height of the last logical line. O(1).
///
/// Must be called **before** `extend_from_heights` so that the last line
/// index still refers to the pre-extension line.
pub fn replace_last_line_height(&mut self, new_height: usize) {
let n = self.prefix_sums.len();
if n < 2 {
return;
}
let last_line = n - 2;
let old_height = self.prefix_sums[last_line + 1] - self.prefix_sums[last_line];
let new_height = new_height as u64;
if new_height == old_height {
return;
}
let delta = new_height.abs_diff(old_height);
if new_height > old_height {
self.prefix_sums[last_line + 1] += delta;
self.total_visual_rows += delta;
} else {
self.prefix_sums[last_line + 1] -= delta;
self.total_visual_rows -= delta;
}
}
} }
// ─── VisualHeightRebuildResult ──────────────────────────────────────────────── // ─── VisualHeightRebuildResult ────────────────────────────────────────────────
@@ -180,6 +223,9 @@ pub fn compute_line_visual_height(
} }
if json_format { if json_format {
let formatted = format_json_line(line_text); let formatted = format_json_line(line_text);
if formatted.len() > MAX_WRAP_INPUT_LEN {
return 1;
}
compute_text_visual_height(&formatted, terminal_width) compute_text_visual_height(&formatted, terminal_width)
} else { } else {
compute_text_visual_height(line_text, terminal_width) compute_text_visual_height(line_text, terminal_width)
@@ -245,20 +291,20 @@ pub fn spawn_indexer(
let file = match std::fs::File::open(&path) { let file = match std::fs::File::open(&path) {
Ok(f) => f, Ok(f) => f,
Err(e) => { Err(e) => {
let _ = tx.send(IndexerMessage::Error { send_cancelable(&tx, IndexerMessage::Error {
generation, generation,
message: e.to_string(), message: e.to_string(),
}); }, &cancel_rx);
return; return;
} }
}; };
let target_len = match file.metadata() { let target_len = match file.metadata() {
Ok(m) => m.len(), Ok(m) => m.len(),
Err(e) => { Err(e) => {
let _ = tx.send(IndexerMessage::Error { send_cancelable(&tx, IndexerMessage::Error {
generation, generation,
message: e.to_string(), message: e.to_string(),
}); }, &cancel_rx);
return; return;
} }
}; };
@@ -275,10 +321,10 @@ pub fn spawn_indexer(
let buf = match buf_reader.fill_buf() { let buf = match buf_reader.fill_buf() {
Ok(b) => b, Ok(b) => b,
Err(e) => { Err(e) => {
let _ = tx.send(IndexerMessage::Error { send_cancelable(&tx, IndexerMessage::Error {
generation, generation,
message: e.to_string(), message: e.to_string(),
}); }, &cancel_rx);
return; return;
} }
}; };
@@ -310,7 +356,7 @@ pub fn spawn_indexer(
} }
if target_len > 0 { if target_len > 0 {
let percent = (chunk_offset as f64 / target_len as f64) * 100.0; let percent = (chunk_offset as f64 / target_len as f64) * 100.0;
let _ = tx.send(IndexerMessage::Progress { let _ = tx.try_send(IndexerMessage::Progress {
generation, generation,
percent, percent,
lines_scanned: newline_count as u64, lines_scanned: newline_count as u64,
@@ -361,18 +407,18 @@ pub fn spawn_indexer(
Ok(_) | Err(_) => None, Ok(_) | Err(_) => None,
}, },
Err(e) => { Err(e) => {
let _ = tx.send(IndexerMessage::Error { send_cancelable(&tx, IndexerMessage::Error {
generation, generation,
message: e.to_string(), message: e.to_string(),
}); }, &cancel_rx);
return; return;
} }
}, },
Err(e) => { Err(e) => {
let _ = tx.send(IndexerMessage::Error { send_cancelable(&tx, IndexerMessage::Error {
generation, generation,
message: e.to_string(), message: e.to_string(),
}); }, &cancel_rx);
return; return;
} }
} }
@@ -391,11 +437,11 @@ pub fn spawn_indexer(
None None
}; };
let _ = tx.send(IndexerMessage::Complete { send_cancelable(&tx, IndexerMessage::Complete {
generation, generation,
reader, reader,
visual_height_index, visual_height_index,
}); }, &cancel_rx);
}); });
rx rx
@@ -455,7 +501,7 @@ pub fn spawn_visual_height_rebuild(
let index = let index =
VisualHeightIndex::build(&visual_heights).with_params(json_format, terminal_width); VisualHeightIndex::build(&visual_heights).with_params(json_format, terminal_width);
let _ = tx.send(VisualHeightRebuildResult { generation, index }); send_cancelable(&tx, VisualHeightRebuildResult { generation, index }, &cancel_rx);
}); });
rx rx
@@ -752,7 +798,7 @@ impl ProgressiveFileReader {
pub fn start_visual_height_rebuild(&mut self, terminal_width: usize, json_format: bool) { pub fn start_visual_height_rebuild(&mut self, terminal_width: usize, json_format: bool) {
if let Some(tx) = self.vh_rebuild_cancel_tx.take() { if let Some(tx) = self.vh_rebuild_cancel_tx.take() {
let _ = tx.send(()); let _ = tx.try_send(());
} }
let (cancel_tx, cancel_rx) = crossbeam_channel::bounded(1); let (cancel_tx, cancel_rx) = crossbeam_channel::bounded(1);
@@ -807,10 +853,10 @@ impl ProgressiveFileReader {
impl Drop for ProgressiveFileReader { impl Drop for ProgressiveFileReader {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(tx) = &self.cancel_tx { if let Some(tx) = &self.cancel_tx {
let _ = tx.send(()); let _ = tx.try_send(());
} }
if let Some(tx) = self.vh_rebuild_cancel_tx.take() { if let Some(tx) = self.vh_rebuild_cancel_tx.take() {
let _ = tx.send(()); let _ = tx.try_send(());
} }
} }
} }
@@ -1339,6 +1385,85 @@ mod tests {
assert_eq!(idx.total_visual_rows(), 6); assert_eq!(idx.total_visual_rows(), 6);
} }
#[test]
fn test_replace_last_line_height_increase() {
let heights = [2, 3];
let mut idx = VisualHeightIndex::build(&heights);
assert_eq!(idx.total_visual_rows(), 5);
assert_eq!(idx.visual_height_of_line(1), 3);
idx.replace_last_line_height(7);
assert_eq!(idx.visual_height_of_line(0), 2);
assert_eq!(idx.visual_height_of_line(1), 7);
assert_eq!(idx.total_visual_rows(), 9);
assert_eq!(idx.cursor_to_first_visual_row(0), 0);
assert_eq!(idx.cursor_to_first_visual_row(1), 2);
}
#[test]
fn test_replace_last_line_height_decrease() {
let heights = [2, 5];
let mut idx = VisualHeightIndex::build(&heights);
idx.replace_last_line_height(1);
assert_eq!(idx.visual_height_of_line(0), 2);
assert_eq!(idx.visual_height_of_line(1), 1);
assert_eq!(idx.total_visual_rows(), 3);
}
#[test]
fn test_replace_last_line_height_same_is_noop() {
let heights = [2, 3];
let mut idx = VisualHeightIndex::build(&heights);
let total_before = idx.total_visual_rows();
idx.replace_last_line_height(3);
assert_eq!(idx.total_visual_rows(), total_before);
}
#[test]
fn test_replace_last_line_height_then_extend() {
let heights = [2, 1];
let mut idx = VisualHeightIndex::build(&heights);
assert_eq!(idx.total_visual_rows(), 3);
idx.replace_last_line_height(4);
idx.extend_from_heights(&[3]);
assert_eq!(idx.line_count(), 3);
assert_eq!(idx.visual_height_of_line(0), 2);
assert_eq!(idx.visual_height_of_line(1), 4);
assert_eq!(idx.visual_height_of_line(2), 3);
assert_eq!(idx.total_visual_rows(), 9);
assert_eq!(idx.cursor_to_first_visual_row(0), 0);
assert_eq!(idx.cursor_to_first_visual_row(1), 2);
assert_eq!(idx.cursor_to_first_visual_row(2), 6);
}
#[test]
fn test_replace_last_line_height_single_line() {
let heights = [5];
let mut idx = VisualHeightIndex::build(&heights);
idx.replace_last_line_height(2);
assert_eq!(idx.visual_height_of_line(0), 2);
assert_eq!(idx.total_visual_rows(), 2);
}
#[test]
fn test_replace_last_line_height_empty_index() {
let heights: [usize; 0] = [];
let mut idx = VisualHeightIndex::build(&heights);
idx.replace_last_line_height(5);
assert_eq!(idx.total_visual_rows(), 0);
}
#[test] #[test]
fn test_spawn_indexer_file_truncated_during_scan() { fn test_spawn_indexer_file_truncated_during_scan() {
let mut content = Vec::new(); let mut content = Vec::new();
@@ -1405,4 +1530,103 @@ mod tests {
Ok(_) => panic!("should have been discarded due to line count mismatch"), Ok(_) => panic!("should have been discarded due to line count mismatch"),
} }
} }
#[test]
fn test_send_cancelable_delivers_on_empty_channel() {
let (tx, rx) = crossbeam_channel::bounded(2);
let (_cancel_tx, cancel_rx) = crossbeam_channel::bounded(1);
send_cancelable(&tx, 42, &cancel_rx);
assert_eq!(rx.try_recv(), Ok(42));
}
#[test]
fn test_send_cancelable_aborts_on_cancel() {
let (tx, rx) = crossbeam_channel::bounded(1);
let (cancel_tx, cancel_rx) = crossbeam_channel::bounded(1);
tx.send("filler").unwrap();
let handle = std::thread::spawn(move || {
send_cancelable(&tx, "important", &cancel_rx);
});
cancel_tx.send(()).unwrap();
handle.join().unwrap();
assert_eq!(rx.try_recv(), Ok("filler"));
}
#[test]
fn test_send_cancelable_drains_when_room_available() {
let (tx, rx) = crossbeam_channel::bounded(1);
let (_cancel_tx, cancel_rx) = crossbeam_channel::bounded(1);
tx.send("first").unwrap();
let rx_clone = rx.clone();
let handle = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(50));
let _ = rx_clone.try_recv();
});
send_cancelable(&tx, "second", &cancel_rx);
handle.join().unwrap();
assert_eq!(rx.try_recv(), Ok("second"));
}
#[test]
fn test_progress_try_send_does_not_block_full_channel() {
let mut content = Vec::new();
for i in 0..50_000 {
writeln!(content, "line number {:08}", i).unwrap();
}
let f = create_temp_file(&content);
let (_cancel_tx, cancel_rx) = crossbeam_channel::bounded(1);
let rx = spawn_indexer(f.path().to_path_buf(), 1, 80, false, cancel_rx);
let mut got_complete = false;
let timeout = std::time::Duration::from_secs(15);
let start = std::time::Instant::now();
while start.elapsed() < timeout {
match rx.recv_timeout(std::time::Duration::from_secs(1)) {
Ok(IndexerMessage::Progress { .. }) => {}
Ok(IndexerMessage::Complete { .. }) => {
got_complete = true;
break;
}
Ok(IndexerMessage::Error { message, .. }) => {
panic!("unexpected error: {}", message);
}
Err(e) => panic!("recv error: {:?}", e),
}
}
assert!(got_complete, "indexer should complete even when Progress fills channel");
}
#[test]
fn test_indexer_cancel_with_full_channel() {
let mut content = Vec::new();
for i in 0..500_000 {
writeln!(content, "line number {:08}", i).unwrap();
}
let f = create_temp_file(&content);
let (cancel_tx, cancel_rx) = crossbeam_channel::bounded(1);
let rx = spawn_indexer(f.path().to_path_buf(), 1, 80, false, cancel_rx);
cancel_tx.send(()).unwrap();
let result = rx.recv_timeout(std::time::Duration::from_secs(5));
match result {
Err(crossbeam_channel::RecvTimeoutError::Timeout)
| Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {}
Ok(IndexerMessage::Complete { .. }) => {}
Ok(IndexerMessage::Error { .. }) => {}
Ok(IndexerMessage::Progress { .. }) => {}
}
}
} }

View File

@@ -56,9 +56,15 @@ impl LruReadCache {
/// on a hit, or fills a cache slot on a miss. Cross-block reads go through /// on a hit, or fills a cache slot on a miss. Cross-block reads go through
/// the spill buffer and are not cached. /// the spill buffer and are not cached.
pub fn get(&mut self, file: &File, offset: u64, len: usize) -> io::Result<&[u8]> { pub fn get(&mut self, file: &File, offset: u64, len: usize) -> io::Result<&[u8]> {
if len == 0 {
return Ok(&[]);
}
let aligned_key = offset & !(BLOCK_ALIGN as u64 - 1); let aligned_key = offset & !(BLOCK_ALIGN as u64 - 1);
let request_end = offset.saturating_add(len as u64); let request_end = offset.checked_add(len as u64).ok_or_else(|| {
let block_end = aligned_key + BLOCK_ALIGN as u64; io::Error::new(io::ErrorKind::InvalidInput, "read range overflows u64")
})?;
let block_end = aligned_key.saturating_add(BLOCK_ALIGN as u64);
if request_end > block_end { if request_end > block_end {
self.spill_buf.resize(len, 0); self.spill_buf.resize(len, 0);
@@ -74,7 +80,8 @@ impl LruReadCache {
} }
let hit_idx = self.slots.iter().position(|slot| { let hit_idx = self.slots.iter().position(|slot| {
slot.block_offset == aligned_key && request_end <= slot.block_offset + slot.len as u64 let slot_end = slot.block_offset.saturating_add(slot.len as u64);
slot.len > 0 && slot.block_offset == aligned_key && request_end <= slot_end
}); });
if let Some(idx) = hit_idx { if let Some(idx) = hit_idx {
@@ -96,8 +103,8 @@ impl LruReadCache {
let slot = &mut self.slots[evict_idx]; let slot = &mut self.slots[evict_idx];
let bytes_read = file.read_at(&mut slot.buf, aligned_key)?; let bytes_read = file.read_at(&mut slot.buf, aligned_key)?;
// Note: get(file, 0, 0) on an empty file now returns Err (old code returned Ok(&[])). // Non-empty reads that return 0 are EOF. Zero-length reads are handled above
// No callers pass len == 0, so this is a safe semantic change. // as a successful no-op.
if bytes_read == 0 { if bytes_read == 0 {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "read 0 bytes")); return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "read 0 bytes"));
} }
@@ -107,7 +114,8 @@ impl LruReadCache {
slot.last_access = self.tick; slot.last_access = self.tick;
self.tick += 1; self.tick += 1;
if request_end > aligned_key + bytes_read as u64 { let bytes_end = aligned_key.saturating_add(bytes_read as u64);
if request_end > bytes_end {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "short read")); return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "short read"));
} }
@@ -118,7 +126,9 @@ impl LruReadCache {
/// Invalidate all cache slots and the spill buffer. /// Invalidate all cache slots and the spill buffer.
pub fn clear(&mut self) { pub fn clear(&mut self) {
for slot in &mut self.slots { for slot in &mut self.slots {
slot.block_offset = 0;
slot.len = 0; slot.len = 0;
slot.last_access = 0;
} }
self.spill_len = 0; self.spill_len = 0;
} }
@@ -314,9 +324,11 @@ mod tests {
cache.clear(); cache.clear();
// All slots should have len == 0. // All slots should be fully reset.
for slot in &cache.slots { for slot in &cache.slots {
assert_eq!(slot.block_offset, 0);
assert_eq!(slot.len, 0); assert_eq!(slot.len, 0);
assert_eq!(slot.last_access, 0);
} }
assert_eq!(cache.spill_len, 0); assert_eq!(cache.spill_len, 0);
@@ -432,4 +444,52 @@ mod tests {
assert_eq!(&line2[..4090], &[b'B'; 4090]); assert_eq!(&line2[..4090], &[b'B'; 4090]);
assert_eq!(line2[4090], b'\n'); assert_eq!(line2[4090], b'\n');
} }
#[test]
fn zero_len_read_is_noop_on_fresh_cache() {
let f = make_file(b"");
let file = File::open(f.path()).unwrap();
let mut cache = ReadCache::new();
let result = cache.get(&file, 0, 0).unwrap();
assert!(result.is_empty());
assert_eq!(cache.tick, 0);
assert!(cache.slots.iter().all(|s| s.len == 0));
}
#[test]
fn zero_len_read_is_noop_on_populated_cache() {
let f = make_file(b"abc");
let file = File::open(f.path()).unwrap();
let mut cache = ReadCache::new();
cache.get(&file, 0, 1).unwrap();
let tick_before = cache.tick;
let result = cache.get(&file, 0, 0).unwrap();
assert!(result.is_empty());
assert_eq!(cache.tick, tick_before);
}
#[test]
fn zero_len_read_at_max_offset_is_ok() {
let f = make_file(b"");
let file = File::open(f.path()).unwrap();
let mut cache = ReadCache::new();
let result = cache.get(&file, u64::MAX, 0).unwrap();
assert!(result.is_empty());
assert_eq!(cache.tick, 0);
assert!(cache.slots.iter().all(|s| s.len == 0));
}
#[test]
fn nonzero_read_range_overflow_returns_invalid_input() {
let f = make_file(b"abc");
let file = File::open(f.path()).unwrap();
let mut cache = ReadCache::new();
let err = cache.get(&file, u64::MAX, 1).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
} }

View File

@@ -1,10 +1,19 @@
/// Maximum input length for wrap/format operations (10 MB). /// Maximum input length for wrap/format operations (10 MB).
/// Lines exceeding this are returned as-is to avoid pathological cases. /// Callers should check against this constant before invoking `wrap_line_chars`
/// to avoid pathological cases on oversized lines.
pub const MAX_WRAP_INPUT_LEN: usize = 10 * 1024 * 1024; pub const MAX_WRAP_INPUT_LEN: usize = 10 * 1024 * 1024;
/// Split a line into chunks of exactly `width` characters (display columns). /// Column spacing for tab stop alignment.
const TAB_WIDTH: usize = 4;
/// Split a line into chunks of exactly `width` display columns.
/// For a log viewer, we want character-level wrapping, not word-level. /// For a log viewer, we want character-level wrapping, not word-level.
/// Uses `unicode-width` for correct CJK/emoji/zero-width handling.
/// Tab characters expand to the next tab-stop boundary and split across
/// rows when the expansion exceeds the remaining width.
pub fn wrap_line_chars(line: &str, width: usize) -> Vec<String> { pub fn wrap_line_chars(line: &str, width: usize) -> Vec<String> {
use unicode_width::UnicodeWidthChar;
if width == 0 { if width == 0 {
return vec![String::new()]; return vec![String::new()];
} }
@@ -15,23 +24,42 @@ pub fn wrap_line_chars(line: &str, width: usize) -> Vec<String> {
let mut row = String::new(); let mut row = String::new();
let mut col = 0; let mut col = 0;
for ch in line.chars() { for ch in line.chars() {
let w = if ch == '\t' { 4 } else { 1 }; if ch == '\t' {
let tab_stop = TAB_WIDTH - (col % TAB_WIDTH);
let mut remaining = tab_stop;
while remaining > 0 {
let avail = width.saturating_sub(col);
let fill = remaining.min(avail);
for _ in 0..fill {
row.push(' ');
}
col += fill;
remaining -= fill;
if col >= width {
result.push(std::mem::take(&mut row));
col = 0;
}
}
} else {
let w = if ch.is_control() {
// Control characters (except tab): width 0, still pushed to preserve content.
// Visible rendering is the caller's responsibility.
0
} else {
ch.width().unwrap_or(0)
};
if col + w > width && !row.is_empty() { if col + w > width && !row.is_empty() {
result.push(std::mem::take(&mut row)); result.push(std::mem::take(&mut row));
col = 0; col = 0;
} }
if ch == '\t' {
row.push_str(" ");
col += 4;
} else {
row.push(ch); row.push(ch);
col += w; col += w;
}
if col >= width { if col >= width {
result.push(std::mem::take(&mut row)); result.push(std::mem::take(&mut row));
col = 0; col = 0;
} }
} }
}
if !row.is_empty() { if !row.is_empty() {
result.push(row); result.push(row);
} }
@@ -96,7 +124,7 @@ mod tests {
#[test] #[test]
fn test_wrap_with_tab() { fn test_wrap_with_tab() {
let result = wrap_line_chars("a\tb", 4); let result = wrap_line_chars("a\tb", 4);
assert_eq!(result, vec!["a", " ", "b"]); assert_eq!(result, vec!["a ", "b"]);
} }
#[test] #[test]
@@ -132,4 +160,75 @@ mod tests {
fn test_max_wrap_input_len_constant() { fn test_max_wrap_input_len_constant() {
assert_eq!(MAX_WRAP_INPUT_LEN, 10 * 1024 * 1024); assert_eq!(MAX_WRAP_INPUT_LEN, 10 * 1024 * 1024);
} }
#[test]
fn test_wrap_cjk_chars() {
let result = wrap_line_chars("你好", 3);
assert_eq!(result, vec!["", ""]);
}
#[test]
fn test_wrap_cjk_ascii_mixed() {
let result = wrap_line_chars("a你好", 4);
assert_eq!(result, vec!["a你", ""]);
}
#[test]
fn test_wrap_zero_width_char() {
let result = wrap_line_chars("a\u{200B}b", 2);
assert_eq!(result, vec!["a\u{200B}b"]);
}
#[test]
fn test_wrap_emoji() {
let result = wrap_line_chars("😀a", 3);
assert_eq!(result, vec!["😀a"]);
}
#[test]
fn test_wrap_emoji_exact_wrap() {
let result = wrap_line_chars("😀a", 2);
assert_eq!(result, vec!["😀", "a"]);
}
#[test]
fn test_wrap_combining_mark() {
// Scalar-width wrapping: combining mark (width 0) stays with next base char,
// not the preceding one, because the base char already triggered a flush.
let result = wrap_line_chars("a\u{0301}b", 1);
assert_eq!(result, vec!["a", "\u{0301}b"]);
}
#[test]
fn test_wrap_cjk_width_one() {
let result = wrap_line_chars("你好", 1);
assert_eq!(result, vec!["", ""]);
}
#[test]
fn test_tab_narrow_width() {
let result = wrap_line_chars("\t", 2);
assert_eq!(result, vec![" ", " "]);
let result = wrap_line_chars("\t", 1);
assert_eq!(result, vec![" ", " ", " ", " "]);
}
#[test]
fn test_tab_stop_alignment() {
assert_eq!(wrap_line_chars("a\tb", 8), vec!["a b"]);
assert_eq!(wrap_line_chars("ab\t", 4), vec!["ab "]);
assert_eq!(wrap_line_chars("abc\tb", 8), vec!["abc b"]);
}
#[test]
fn test_tab_at_line_boundary() {
let result = wrap_line_chars("a\tb", 4);
assert_eq!(result, vec!["a ", "b"]);
}
#[test]
fn test_tab_regression_ab_tab() {
let result = wrap_line_chars("ab\t", 4);
assert_eq!(result, vec!["ab "]);
}
} }

View File

@@ -16,7 +16,14 @@
// 类似于 Python 的 dict、JavaScript 的 Object/Map、Java 的 HashMap。 // 类似于 Python 的 dict、JavaScript 的 Object/Map、Java 的 HashMap。
// 它存储键值对key-value pairs可以通过键快速查找对应的值。 // 它存储键值对key-value pairs可以通过键快速查找对应的值。
// 这里用 HashMap<String, Value> 来存储 JSON 中除 timestamp/level 之外的其他字段。 // 这里用 HashMap<String, Value> 来存储 JSON 中除 timestamp/level 之外的其他字段。
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
// serde::de 中的 Visitor / MapAccess 允许我们自定义 JSON 对象的反序列化过程。
// 默认的 serde_json::from_str::<HashMap<_, _>>() 遇到重复键时会采用"后者覆盖前者"last-wins
// 前面的值被静默丢弃。这里我们通过自定义 Visitor 在反序列化过程中逐个观察 key-value 对,
// 在保持 last-wins 行为的同时,将重复 key 的所有值记录到 DuplicateKey 中。
use serde::de::{MapAccess, Visitor};
use serde::Deserializer;
// serde_json::Value — 来自 serde_json 库Rust 中最流行的 JSON 处理库)。 // serde_json::Value — 来自 serde_json 库Rust 中最流行的 JSON 处理库)。
// Value 是一个枚举类型,可以表示任意 JSON 值: // Value 是一个枚举类型,可以表示任意 JSON 值:
@@ -32,7 +39,20 @@ use serde_json::Value;
// ─── 引入项目内部类型 ────────────────────────────────────────────────────── // ─── 引入项目内部类型 ──────────────────────────────────────────────────────
// crate 表示"当前项目crate"。 // crate 表示"当前项目crate"。
// types 模块中定义了 LogEntry一条日志记录和 LogLevel日志级别如 INFO/ERROR // types 模块中定义了 LogEntry一条日志记录和 LogLevel日志级别如 INFO/ERROR
use crate::types::{LogEntry, LogLevel}; use crate::types::{DuplicateKey, LogEntry, LogLevel};
// ─── strip_bom 辅助函数 ──────────────────────────────────────────────────
// 剥离行首的 UTF-8 BOMByte Order Mark, U+FEFF
//
// Windows 环境和某些导出工具生成的文件会在行首插入 BOM
// 而 serde_json 不接受 BOM 前缀的 JSON 文本(会报 "expected value" 错误)。
// 只剥离一个前导 BOM不处理多个 BOM 或行内 BOM那些是畸形输入
//
// 参数: line: &str — 输入字符串切片。
// 返回: &str — 去掉 BOM 后的字符串切片(借用原始字符串,零分配)。
fn strip_bom(line: &str) -> &str {
line.strip_prefix('\u{FEFF}').unwrap_or(line)
}
// ─── detect_json_log 函数 ────────────────────────────────────────────────── // ─── detect_json_log 函数 ──────────────────────────────────────────────────
// 检测一行文本是否是一个 JSON 对象。 // 检测一行文本是否是一个 JSON 对象。
@@ -63,7 +83,89 @@ pub fn detect_json_log(line: &str) -> bool {
// 则匹配成功。_ 是通配符,表示"不关心对象里面的具体内容"。 // 则匹配成功。_ 是通配符,表示"不关心对象里面的具体内容"。
// //
// 如果匹配到 Ok(Value::Object(_)) 返回 true否则返回 false。 // 如果匹配到 Ok(Value::Object(_)) 返回 true否则返回 false。
matches!(serde_json::from_str::<Value>(line), Ok(Value::Object(_))) matches!(serde_json::from_str::<Value>(strip_bom(line)), Ok(Value::Object(_)))
}
// ─── DuplicateKeyVisitor ──────────────────────────────────────────────────
// 自定义 serde Visitor在反序列化 JSON 对象时检测重复 key。
//
// 工作原理:
// serde 的 MapAccess trait 允许我们逐个遍历 JSON 对象的 key-value 对。
// 每读到一个 (key, value),我们:
// 1. 检查这个 key 是否已经见过(通过 HashSet
// 2. 如果是重复 key记录到 Vec<DuplicateKey> 中(包含所有出现过的值)
// 3. 将 key-value 插入 Maplast-wins与 serde_json 默认行为一致)
//
// 这样既保持了兼容性last-wins又不丢失信息所有值都记录在 DuplicateKey 中)。
struct DuplicateKeyVisitor;
impl<'de> Visitor<'de> for DuplicateKeyVisitor {
// 返回类型:(serde_json::Map, 重复 key 列表)
type Value = (serde_json::Map<String, Value>, Vec<DuplicateKey>);
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a JSON object")
}
fn visit_map<A>(self, mut access: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut map = serde_json::Map::new();
let mut seen = HashSet::new();
let mut duplicates: Vec<DuplicateKey> = Vec::new();
while let Some((key, value)) = access.next_entry::<String, Value>()? {
if !seen.insert(key.clone()) {
// 重复 key将之前 map 中的值和当前值都记录下来
if let Some(existing) = duplicates.iter_mut().find(|d| d.key == key) {
// 同一个 key 第三次及以上出现:追加当前值
existing.values.push(value.clone());
} else {
// 同一个 key 第二次出现:记录第一次的值 + 当前值
let prev_value = map.get(&key).cloned().unwrap_or(Value::Null);
duplicates.push(DuplicateKey {
key: key.clone(),
values: vec![prev_value, value.clone()],
});
}
}
// last-wins后出现的值覆盖前面的值与 serde_json 默认行为一致
map.insert(key, value);
}
Ok((map, duplicates))
}
}
/// 使用自定义 Visitor 解析 JSON 对象,同时检测重复 key。
///
/// 返回 (serde_json::Map, Vec<DuplicateKey>)
/// - Map 中存储所有 key-value重复 key 取 last-wins
/// - Vec 中记录所有重复 key 及其全部值
fn parse_json_object_with_duplicates(
json: &str,
) -> Option<(serde_json::Map<String, Value>, Vec<DuplicateKey>)> {
let mut deserializer = serde_json::Deserializer::from_str(json);
Some(deserializer.deserialize_map(DuplicateKeyVisitor).ok()?)
}
// ─── take_string_field_from_map 辅助函数 ──────────────────────────────────
// 从 serde_json::Map 中安全提取字符串字段。
// 功能与原 take_string_field 相同,但操作 serde_json::Map 而非 HashMap。
fn take_string_field_from_map(
obj: &mut serde_json::Map<String, Value>,
keys: &[&str],
) -> Option<String> {
for key in keys {
if obj.get(*key).is_some_and(Value::is_string) {
let Some(Value::String(v)) = obj.remove(*key) else {
unreachable!("value was checked as string");
};
return Some(v);
}
}
None
} }
// ─── parse_line 函数 ────────────────────────────────────────────────────── // ─── parse_line 函数 ──────────────────────────────────────────────────────
@@ -73,107 +175,34 @@ pub fn detect_json_log(line: &str) -> bool {
// 返回: Option<LogEntry> — 解析成功返回 Some(LogEntry),失败或不合法返回 None。 // 返回: Option<LogEntry> — 解析成功返回 Some(LogEntry),失败或不合法返回 None。
// Option 是 Rust 的可选类型Some(值) 表示有值None 表示没有值。 // Option 是 Rust 的可选类型Some(值) 表示有值None 表示没有值。
pub fn parse_line(line: &str) -> Option<LogEntry> { pub fn parse_line(line: &str) -> Option<LogEntry> {
// ─── 跳过空行 ────────────────────────────────────────────────────────── let line = strip_bom(line);
// line.trim() 去除首尾空白字符(空格、制表符、换行符等)。
// .is_empty() 检查是否为空字符串。
// 如果去除空白后是空的,说明是空行,不需要解析,直接返回 None。
if line.trim().is_empty() { if line.trim().is_empty() {
return None; return None;
} }
// ─── 解析 JSON 为 HashMap ────────────────────────────────────────────── // ─── 使用自定义 Visitor 解析 JSON ──────────────────────────────────
// serde_json::from_str(line) 尝试将字符串解析为 JSON // 通过 DuplicateKeyVisitor 反序列化,在保持 last-wins 的同时检测重复 key
// 由于我们声明了 HashMap<String, Value> 类型Rust 会自动将 JSON 对象 // 返回的 (serde_json::Map, Vec<DuplicateKey>) 中:
// 转换为 HashMap其中每个键是 String每个值是 serde_json::Value。 // - Map 包含所有 key-value重复 key 取最后一个值)
// // - Vec 记录了所有重复 key 及其出现过的全部值
// .ok() 将 Result 转换为 Option let (mut obj, duplicate_keys) = parse_json_object_with_duplicates(line)?;
// Ok(值) → Some(值)
// Err(_) → None
//
// 末尾的 ? 是"问号操作符"try operator在这里的作用是
// 如果 .ok() 返回 None即 JSON 解析失败),则整个函数直接返回 None。
// 如果返回 Some(hashmap),则将 hashmap 取出并绑定到 fields 变量。
//
// let mut 表示这是一个"可变变量"mutable variable
// 后续代码会修改这个 HashMap从中删除已识别的字段
let mut fields: HashMap<String, Value> = serde_json::from_str(line).ok()?;
// ─── 保存原始行内容 ──────────────────────────────────────────────────
// line.to_string() 将 &str字符串切片引用转换为 String拥有所有权的字符串
// 保存原始行是为了在 UI 中显示未经修改的原始日志内容。
let raw_line = line.to_string(); let raw_line = line.to_string();
let timestamp = take_string_field_from_map(&mut obj, &["timestamp", "time", "ts", "@timestamp"]);
// ─── 提取时间戳字段 ────────────────────────────────────────────────── let level = take_string_field_from_map(&mut obj, &["level", "lvl", "severity"])
// 这段代码尝试从 JSON 中提取时间戳,逻辑如下:
//
// 1. ["timestamp", "time", "ts", "@timestamp"] — 候选键名数组。
// 不同日志系统使用不同的时间戳字段名,这里列出常见的几种。
//
// 2. .iter() — 创建数组的迭代器,可以逐个遍历元素。
//
// 3. .find_map(|key| fields.remove(*key)) — 对每个候选键名:
// - fields.remove(*key): 尝试从 HashMap 中移除该键并返回对应的值。
// 如果键不存在remove 返回 None。
// *key 是解引用deref将 &str引用转换为 str因为 remove 接受 &str 类型。
// - find_map: 遍历所有候选键,返回第一个 Some(值) 的结果。
// 即找到第一个存在的键就停止。
//
// 4. .and_then(|v| v.as_str().map(String::from)) — 如果找到了时间戳值:
// - v.as_str(): 尝试将 serde_json::Value 转换为 &str字符串切片
// 如果 Value 不是字符串类型(比如是数字),返回 None。
// - .map(String::from): 如果是字符串,将其转换为 String拥有所有权的字符串
// - and_then: 类似于 map但用于"扁平化"嵌套的 Option。
// 如果 as_str() 返回 None整个链返回 None。
let timestamp = ["timestamp", "time", "ts", "@timestamp"]
.iter()
.find_map(|key| fields.remove(*key))
.and_then(|v| v.as_str().map(String::from));
// ─── 提取日志级别字段 ──────────────────────────────────────────────
// 与时间戳提取类似,但多了一步:将字符串解析为 LogLevel 枚举。
let level = ["level", "lvl", "severity"]
.iter()
.find_map(|key| fields.remove(*key))
.and_then(|v| v.as_str().map(String::from))
// .map(|s| s.parse::<LogLevel>(...)) — 尝试将字符串解析为 LogLevel 枚举。
// parse::<LogLevel> 中的 ::<LogLevel> 是泛型参数turbofish 语法),
// 指定我们要将字符串解析为 LogLevel 类型。
//
// .unwrap_or_else(|e| match e {}) — 错误处理:
// - 如果解析成功,直接返回 LogLevel 值。
// - 如果解析失败(字符串不匹配任何已知的日志级别),执行闭包。
// - |e| match e {}: 这个闭包接收解析错误 e用 match e {} 进行"穷尽匹配"。
// 由于 LogLevel 的 parse 错误类型是一个空枚举(没有任何变体),
// match e {} 意味着"这个分支永远不会执行"unreachable
// 但实际上,如果 parse 失败unwrap_or_else 不会执行这个闭包——
// 等等,这里有个细微之处:
// unwrap_or_else 只在 Err 时执行闭包,但 match e {} 对空枚举是合法的
// (因为空枚举没有任何可能的值,所以 match 是穷尽的)。
// 不过这里的实际效果是:如果 parse 失败,整个 .map() 返回 None
// (因为 unwrap_or_else 返回的类型是 LogLevel而空 match 不会有返回值)。
//
// 实际上更准确的解释parse() 的错误类型是 Infallible不可失败的
// 即解析总是成功。所以 unwrap_or_else 永远不会被执行。
// 但即使如此unwrap_or_else 的闭包也需要类型正确match e {} 满足这一点。
.map(|s| s.parse::<LogLevel>().unwrap_or_else(|e| match e {})); .map(|s| s.parse::<LogLevel>().unwrap_or_else(|e| match e {}));
// ─── 构建 LogEntry 并返回 ────────────────────────────────────────── // serde_json::Map → HashMap剩余字段转为 HashMap 存入 fields
// 此时 fields HashMap 中还剩下未被提取的字段(如 message、自定义字段等 let fields: HashMap<String, Value> = obj.into_iter().collect();
// timestamp 和 level 已经从 fields 中移除了(通过 remove
//
// Some(LogEntry { ... }) — 使用结构体字面量创建 LogEntry 实例,
// 并用 Some() 包裹表示"有值"。
Some(LogEntry { Some(LogEntry {
// line_number 设为 0由调用者如 parse_line_with_number设置正确的值。
line_number: 0, line_number: 0,
// 原始行内容。
raw_line, raw_line,
// 时间戳(可能为 None如果 JSON 中没有时间戳字段)。
timestamp, timestamp,
// 日志级别(可能为 None如果 JSON 中没有级别字段)。
level, level,
// 剩余的 JSON 字段(已移除 timestamp 和 level
fields, fields,
duplicate_keys,
}) })
} }
@@ -317,6 +346,13 @@ mod tests {
assert_eq!(parse_line(warn_line).unwrap().level, Some(LogLevel::Warn)); assert_eq!(parse_line(warn_line).unwrap().level, Some(LogLevel::Warn));
} }
#[test]
// Regression: level field with surrounding whitespace should still be recognized.
fn test_level_whitespace_in_json() {
let line = r#"{"level":" WARN ","message":"test"}"#;
assert_eq!(parse_line(line).unwrap().level, Some(LogLevel::Warn));
}
#[test] #[test]
// 测试所有候选时间戳键名timestamp, time, ts, @timestamp都能被识别。 // 测试所有候选时间戳键名timestamp, time, ts, @timestamp都能被识别。
fn test_timestamp_key_names() { fn test_timestamp_key_names() {
@@ -357,4 +393,174 @@ mod tests {
assert_eq!(entry.level, Some(LogLevel::Info), "failed for key: {key}"); assert_eq!(entry.level, Some(LogLevel::Info), "failed for key: {key}");
} }
} }
#[test]
// 测试:数字类型的 level 值不应被提取,应保留在 fields 中。
fn test_numeric_level_preserved_in_fields() {
let line = r#"{"level":30,"message":"hello"}"#;
let entry = parse_line(line).unwrap();
// level 不是字符串,应返回 None。
assert!(entry.level.is_none());
// 数字 level 应保留在 fields 中,不被静默丢弃。
assert_eq!(entry.fields.get("level"), Some(&Value::Number(30.into())));
// message 仍正常存在。
assert_eq!(
entry.fields.get("message"),
Some(&Value::String("hello".to_string()))
);
}
#[test]
// 测试:数字类型的 timestamp 值不应被提取,应保留在 fields 中。
fn test_numeric_timestamp_preserved_in_fields() {
let line = r#"{"timestamp":1718000000,"message":"hello"}"#;
let entry = parse_line(line).unwrap();
// timestamp 不是字符串,应返回 None。
assert!(entry.timestamp.is_none());
// 数字 timestamp 应保留在 fields 中。
assert_eq!(
entry.fields.get("timestamp"),
Some(&Value::Number(1718000000.into()))
);
}
#[test]
// 测试:当第一个候选键是数字时,应回退到下一个字符串类型的候选键。
fn test_fallback_to_string_key() {
let line = r#"{"level":30,"lvl":"INFO","message":"hello"}"#;
let entry = parse_line(line).unwrap();
// "level" 是数字,应跳过;"lvl" 是字符串,应成功提取。
assert_eq!(entry.level, Some(LogLevel::Info));
// 数字 "level" 保留在 fields 中。
assert_eq!(entry.fields.get("level"), Some(&Value::Number(30.into())));
// "lvl" 已被成功提取并从 fields 中移除。
assert!(entry.fields.get("lvl").is_none());
}
#[test]
// 测试timestamp 的 fallback 行为 — 数字 timestamp 被保留,字符串 time 被提取。
fn test_timestamp_fallback_preserves_numeric() {
let line = r#"{"timestamp":1718000000,"time":"2024-01-01T00:00:00Z"}"#;
let entry = parse_line(line).unwrap();
// "timestamp" 是数字,跳过;"time" 是字符串,成功提取。
assert_eq!(entry.timestamp, Some("2024-01-01T00:00:00Z".to_string()));
// 数字 "timestamp" 保留在 fields 中。
assert_eq!(
entry.fields.get("timestamp"),
Some(&Value::Number(1718000000.into()))
);
// "time" 已被提取并从 fields 中移除。
assert!(entry.fields.get("time").is_none());
}
#[test]
fn test_bom_prefixed_json() {
let line = "\u{FEFF}{\"level\":\"INFO\",\"message\":\"hello\"}";
let entry = parse_line(line).unwrap();
assert_eq!(entry.level, Some(LogLevel::Info));
assert_eq!(
entry.fields.get("message"),
Some(&Value::String("hello".into()))
);
}
#[test]
fn test_bom_prefixed_detect() {
assert!(detect_json_log("\u{FEFF}{\"level\":\"INFO\"}"));
}
#[test]
fn test_bom_only_whitespace() {
assert!(parse_line("\u{FEFF} ").is_none());
}
#[test]
fn test_bom_stripped_from_raw_line() {
let line = "\u{FEFF}{\"level\":\"INFO\",\"message\":\"hello\"}";
let entry = parse_line(line).unwrap();
assert_eq!(entry.raw_line, "{\"level\":\"INFO\",\"message\":\"hello\"}");
}
#[test]
fn test_internal_bom_not_stripped() {
let line = "{\"message\":\"\u{FEFF}hello\"}";
let entry = parse_line(line).unwrap();
assert_eq!(
entry.fields.get("message"),
Some(&Value::String("\u{FEFF}hello".into()))
);
}
// ─── 重复 key 检测测试 ──────────────────────────────────────────────
#[test]
fn test_no_duplicate_keys_normal_json() {
let line = r#"{"level":"INFO","message":"hello"}"#;
let entry = parse_line(line).unwrap();
assert!(entry.duplicate_keys.is_empty());
}
#[test]
fn test_duplicate_message_key_detected() {
let line = r#"{"level":"INFO","message":"first","message":"second"}"#;
let entry = parse_line(line).unwrap();
// last-wins: fields 中保留第二个值
assert_eq!(
entry.fields.get("message"),
Some(&Value::String("second".into()))
);
// 重复 key 记录中包含所有值
assert_eq!(entry.duplicate_keys.len(), 1);
assert_eq!(entry.duplicate_keys[0].key, "message");
assert_eq!(entry.duplicate_keys[0].values.len(), 2);
assert_eq!(entry.duplicate_keys[0].values[0], Value::String("first".into()));
assert_eq!(entry.duplicate_keys[0].values[1], Value::String("second".into()));
}
#[test]
fn test_duplicate_key_last_wins() {
let line = r#"{"msg":"a","msg":"b","msg":"c"}"#;
let entry = parse_line(line).unwrap();
// last-wins: 最终值是 "c"
assert_eq!(entry.fields.get("msg"), Some(&Value::String("c".into())));
// 三个值都被记录
assert_eq!(entry.duplicate_keys.len(), 1);
assert_eq!(entry.duplicate_keys[0].values.len(), 3);
assert_eq!(entry.duplicate_keys[0].values[0], Value::String("a".into()));
assert_eq!(entry.duplicate_keys[0].values[1], Value::String("b".into()));
assert_eq!(entry.duplicate_keys[0].values[2], Value::String("c".into()));
}
#[test]
fn test_multiple_different_duplicate_keys() {
let line = r#"{"a":"1","b":"2","a":"3","b":"4"}"#;
let entry = parse_line(line).unwrap();
assert_eq!(entry.duplicate_keys.len(), 2);
let dup_a = entry.duplicate_keys.iter().find(|d| d.key == "a").unwrap();
let dup_b = entry.duplicate_keys.iter().find(|d| d.key == "b").unwrap();
assert_eq!(dup_a.values.len(), 2);
assert_eq!(dup_b.values.len(), 2);
}
#[test]
fn test_duplicate_level_key_detected() {
let line = r#"{"level":"INFO","level":"ERROR","message":"hello"}"#;
let entry = parse_line(line).unwrap();
// last-wins: level 被提取为 ERROR
assert_eq!(entry.level, Some(LogLevel::Error));
// 重复 key 被记录
assert_eq!(entry.duplicate_keys.len(), 1);
assert_eq!(entry.duplicate_keys[0].key, "level");
assert_eq!(entry.duplicate_keys[0].values.len(), 2);
}
#[test]
fn test_duplicate_timestamp_key_detected() {
let line = r#"{"timestamp":"2024-01-01","timestamp":"2024-06-01"}"#;
let entry = parse_line(line).unwrap();
// last-wins: timestamp 提取为后者
assert_eq!(entry.timestamp, Some("2024-06-01".to_string()));
assert_eq!(entry.duplicate_keys.len(), 1);
assert_eq!(entry.duplicate_keys[0].key, "timestamp");
}
} }

View File

@@ -100,13 +100,21 @@ fn detect_level_from_text(line: &str) -> Option<LogLevel> {
) )
} }
// ─── is_ident_char ─────────────────────────────────────────────────────────
/// Whether a byte looks like an ASCII identifier continuation character
/// (letter / digit / underscore). Log-level keywords must NOT be adjacent to
/// such characters to count as a valid word boundary.
fn is_ident_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
// ─── is_word_boundary ─────────────────────────────────────────────────────── // ─── is_word_boundary ───────────────────────────────────────────────────────
/// Check that the match at `start..start+len` is surrounded by non-alphabetic /// Check that the match at `start..start+len` is surrounded by non-identifier
/// characters (or the string edge). /// characters (or the string edge).
fn is_word_boundary(text: &str, start: usize, len: usize) -> bool { fn is_word_boundary(text: &str, start: usize, len: usize) -> bool {
let before_ok = start == 0 || !text.as_bytes()[start - 1].is_ascii_alphabetic(); let before_ok = start == 0 || !is_ident_char(text.as_bytes()[start - 1]);
let after_idx = start + len; let after_idx = start + len;
let after_ok = after_idx >= text.len() || !text.as_bytes()[after_idx].is_ascii_alphabetic(); let after_ok = after_idx >= text.len() || !is_ident_char(text.as_bytes()[after_idx]);
before_ok && after_ok before_ok && after_ok
} }
@@ -209,4 +217,36 @@ mod tests {
let line = format!("{prefix} ERROR something"); let line = format!("{prefix} ERROR something");
assert_eq!(detect_level(&line), Some(LogLevel::Error)); assert_eq!(detect_level(&line), Some(LogLevel::Error));
} }
#[test]
fn test_boundary_rejects_trailing_digits() {
assert_eq!(detect_level("ERROR123"), None);
assert_eq!(detect_level("WARN2: bad"), None);
assert_eq!(detect_level("ERR2"), None);
}
#[test]
fn test_boundary_rejects_underscore() {
assert_eq!(detect_level("INFO_foo"), None);
assert_eq!(detect_level("DBG_value=5"), None);
}
#[test]
fn test_boundary_rejects_leading_digits_and_underscore() {
assert_eq!(detect_level("123ERROR: fail"), None);
assert_eq!(detect_level("foo_ERROR: fail"), None);
assert_eq!(detect_level("1WRN"), None);
}
#[test]
fn test_boundary_accepts_valid_suffixes() {
assert_eq!(detect_level("ERROR: fail"), Some(LogLevel::Error));
assert_eq!(detect_level("[ERROR] fail"), Some(LogLevel::Error));
assert_eq!(detect_level("ERROR fail"), Some(LogLevel::Error));
}
#[test]
fn test_boundary_camel_case_regression() {
assert_eq!(detect_level("errorLevel"), None);
}
} }

View File

@@ -71,13 +71,17 @@ impl FromStr for LogLevel {
// 接收一个字符串切片 &str返回 Result<LogLevel, Infallible>。 // 接收一个字符串切片 &str返回 Result<LogLevel, Infallible>。
// 由于 Err 类型是 Infallible实际上返回值总是 Ok(LogLevel)。 // 由于 Err 类型是 Infallible实际上返回值总是 Ok(LogLevel)。
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
// `s.to_uppercase()` — 字符串转换为大写,实现不区分大小写的匹配 // `s.trim()` — 去除字符串前后的 Unicode 空白字符
// 例如 " WARN " → "WARN""\tINFO\n" → "INFO"。
let trimmed = s.trim();
// `trimmed.to_uppercase()` — 将 trimmed 后的字符串转换为大写,实现不区分大小写的匹配。
// 例如 "info"、"Info"、"INFO" 都会被转换为 "INFO"。 // 例如 "info"、"Info"、"INFO" 都会被转换为 "INFO"。
// 返回一个新的 String堆分配 // 返回一个新的 String堆分配
// //
// `.as_str()` — 将 String 转换回 &str字符串切片引用 // `.as_str()` — 将 String 转换回 &str字符串切片引用
// 因为 match 需要匹配 &str 而不是 String。 // 因为 match 需要匹配 &str 而不是 String。
match s.to_uppercase().as_str() { match trimmed.to_uppercase().as_str() {
// `|` 在 match 分支中表示"或"multiple patterns // `|` 在 match 分支中表示"或"multiple patterns
// "ERROR" | "ERR" | "SEVERE" | "FATAL" 都匹配到 LogLevel::Error。 // "ERROR" | "ERR" | "SEVERE" | "FATAL" 都匹配到 LogLevel::Error。
"ERROR" | "ERR" | "SEVERE" | "FATAL" => Ok(LogLevel::Error), "ERROR" | "ERR" | "SEVERE" | "FATAL" => Ok(LogLevel::Error),
@@ -86,9 +90,9 @@ impl FromStr for LogLevel {
"DEBUG" | "DBG" => Ok(LogLevel::Debug), "DEBUG" | "DBG" => Ok(LogLevel::Debug),
"TRACE" | "TRC" => Ok(LogLevel::Trace), "TRACE" | "TRC" => Ok(LogLevel::Trace),
// `_` 是通配符,匹配所有未被上面分支捕获的值。 // `_` 是通配符,匹配所有未被上面分支捕获的值。
// 对于未知级别,包装为 Unknown 并保存原始字符串。 // 对于未知级别,包装为 Unknown 并保存 trimmed 后的字符串。
// s.to_string() 将 &str 转换为 String注意这里用原始 s,不是大写后的)。 // s.to_string() 将 &str 转换为 String注意这里用 trimmed不是原始 s
_ => Ok(LogLevel::Unknown(s.to_string())), _ => Ok(LogLevel::Unknown(trimmed.to_string())),
} }
} }
} }
@@ -119,6 +123,18 @@ impl fmt::Display for LogLevel {
} }
// ─── LogEntry 结构体 ──────────────────────────────────────────────────────── // ─── LogEntry 结构体 ────────────────────────────────────────────────────────
/// 记录 JSON 日志中出现的重复 key 信息
///
/// 当 JSON 对象中同一个 key 出现多次时serde_json 默认 last-wins后值覆盖前值
/// 前面的值会静默丢失。此结构记录所有重复出现的 key 及其全部值,供 UI 展示警告。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DuplicateKey {
/// 重复的 key 名称
pub key: String,
/// 该 key 出现的所有值(按出现顺序排列,最后一个值是 fields 中的最终值)
pub values: Vec<Value>,
}
/// 一行解析后的日志 /// 一行解析后的日志
/// ///
/// 表示日志文件中经过解析器处理后的一行内容。 /// 表示日志文件中经过解析器处理后的一行内容。
@@ -144,6 +160,9 @@ pub struct LogEntry {
/// HashMap<String, Value> 是一个字典,键是字段名,值是 JSON 值。 /// HashMap<String, Value> 是一个字典,键是字段名,值是 JSON 值。
/// 例如 {"message": "hello", "request_id": "abc123"}。 /// 例如 {"message": "hello", "request_id": "abc123"}。
pub fields: HashMap<String, Value>, pub fields: HashMap<String, Value>,
/// JSON 中重复出现的 key 记录(正常日志为空 Vec
pub duplicate_keys: Vec<DuplicateKey>,
} }
// ─── SearchResult 结构体 ──────────────────────────────────────────────────── // ─── SearchResult 结构体 ────────────────────────────────────────────────────
@@ -288,6 +307,34 @@ mod tests {
); );
} }
#[test]
fn test_from_str_whitespace_trimmed() {
assert_eq!(" WARN ".parse::<LogLevel>(), Ok(LogLevel::Warn));
assert_eq!("\tINFO".parse::<LogLevel>(), Ok(LogLevel::Info));
assert_eq!("ERROR\n".parse::<LogLevel>(), Ok(LogLevel::Error));
assert_eq!(" debug ".parse::<LogLevel>(), Ok(LogLevel::Debug));
assert_eq!("\tTRACE\t".parse::<LogLevel>(), Ok(LogLevel::Trace));
}
#[test]
fn test_from_str_whitespace_unknown_trimmed() {
// Unknown stores the trimmed value, not the original.
assert_eq!(
" CUSTOM ".parse::<LogLevel>(),
Ok(LogLevel::Unknown("CUSTOM".into()))
);
// Pure whitespace becomes Unknown("").
assert_eq!(
" ".parse::<LogLevel>(),
Ok(LogLevel::Unknown("".into()))
);
// Internal whitespace is NOT collapsed.
assert_eq!(
"W ARN".parse::<LogLevel>(),
Ok(LogLevel::Unknown("W ARN".into()))
);
}
#[test] #[test]
// 测试LogLevel 的 Display 输出格式是否正确。 // 测试LogLevel 的 Display 输出格式是否正确。
fn test_display_output() { fn test_display_output() {
@@ -319,6 +366,7 @@ mod tests {
timestamp: Some("2024-01-01T00:00:00".to_string()), timestamp: Some("2024-01-01T00:00:00".to_string()),
level: Some(LogLevel::Info), level: Some(LogLevel::Info),
fields, fields,
duplicate_keys: vec![],
}; };
// 逐字段验证。 // 逐字段验证。

View File

@@ -13,6 +13,7 @@ pub enum FileEvent {
Truncated { new_size: u64 }, Truncated { new_size: u64 },
Rotated { new_inode: u64 }, Rotated { new_inode: u64 },
Removed, Removed,
WatcherError { message: String },
} }
// ─── get_inode ────────────────────────────────────────────────────────────── // ─── get_inode ──────────────────────────────────────────────────────────────
@@ -34,6 +35,43 @@ struct WatchState {
last_inode: u64, last_inode: u64,
} }
fn process_event(event: Event, watch_path: &Path, state: &mut WatchState) -> Option<FileEvent> {
if !event.paths.iter().any(|p| p == watch_path) {
return None;
}
match event.kind {
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Any => {}
EventKind::Remove(_) => return Some(FileEvent::Removed),
_ => return None,
}
let current_inode = get_inode(watch_path).unwrap_or(0);
let current_size = std::fs::metadata(watch_path).map(|m| m.len()).unwrap_or(0);
if current_inode != 0 && state.last_inode != 0 && current_inode != state.last_inode {
state.last_inode = current_inode;
state.last_size = current_size;
Some(FileEvent::Rotated {
new_inode: current_inode,
})
} else if current_size > state.last_size {
state.last_size = current_size;
state.last_inode = current_inode;
Some(FileEvent::Appended {
new_size: current_size,
})
} else if current_size < state.last_size {
state.last_size = current_size;
state.last_inode = current_inode;
Some(FileEvent::Truncated {
new_size: current_size,
})
} else {
None
}
}
// ─── FileWatcher ──────────────────────────────────────────────────────────── // ─── FileWatcher ────────────────────────────────────────────────────────────
pub struct FileWatcher { pub struct FileWatcher {
rx: Receiver<FileEvent>, rx: Receiver<FileEvent>,
@@ -56,50 +94,22 @@ impl FileWatcher {
notify::recommended_watcher(move |res: std::result::Result<Event, notify::Error>| { notify::recommended_watcher(move |res: std::result::Result<Event, notify::Error>| {
let event = match res { let event = match res {
Ok(e) => e, Ok(e) => e,
Err(_) => return, Err(error) => {
let _ = tx.try_send(FileEvent::WatcherError {
message: error.to_string(),
});
return;
}
}; };
match event.kind {
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Any => {}
EventKind::Remove(_) => {
let _ = tx.try_send(FileEvent::Removed);
return;
}
_ => return,
}
if !event.paths.iter().any(|p| p == &watch_path) {
return;
}
let current_inode = get_inode(&watch_path).unwrap_or(0);
let current_size = std::fs::metadata(&watch_path).map(|m| m.len()).unwrap_or(0);
let mut st = state.lock().unwrap_or_else(|poison| { let mut st = state.lock().unwrap_or_else(|poison| {
// Recover from poisoned mutex — state only tracks last_size // Recover from poisoned mutex — state only tracks last_size
// and last_inode for event dedup. Stale values at worst // and last_inode for event dedup. Stale values at worst
// cause a duplicate event, which is harmless. // cause a duplicate event, which is harmless.
poison.into_inner() poison.into_inner()
}); });
if let Some(fe) = process_event(event, &watch_path, &mut st) {
if current_inode != 0 && st.last_inode != 0 && current_inode != st.last_inode { let _ = tx.try_send(fe);
let _ = tx.try_send(FileEvent::Rotated {
new_inode: current_inode,
});
st.last_inode = current_inode;
st.last_size = current_size;
} else if current_size > st.last_size {
let _ = tx.try_send(FileEvent::Appended {
new_size: current_size,
});
st.last_size = current_size;
st.last_inode = current_inode;
} else if current_size < st.last_size {
let _ = tx.try_send(FileEvent::Truncated {
new_size: current_size,
});
st.last_size = current_size;
st.last_inode = current_inode;
} }
})?; })?;
@@ -144,6 +154,56 @@ mod tests {
events events
} }
#[test]
fn test_remove_wrong_path_ignored() {
let dir = tempfile::tempdir().expect("create temp dir");
let watched = dir.path().join("watched.log");
let other = dir.path().join("other.log");
std::fs::write(&watched, b"hello\n").expect("write watched");
std::fs::write(&other, b"other\n").expect("write other");
let mut state = WatchState {
last_size: 6,
last_inode: get_inode(&watched).unwrap_or(0),
};
let event = Event {
kind: EventKind::Remove(notify::event::RemoveKind::File),
paths: vec![other.clone()],
attrs: Default::default(),
};
let result = process_event(event, &watched, &mut state);
assert_eq!(
result, None,
"Remove for non-watched path should be ignored"
);
}
#[test]
fn test_remove_correct_path_emits_removed() {
let dir = tempfile::tempdir().expect("create temp dir");
let watched = dir.path().join("watched.log");
std::fs::write(&watched, b"hello\n").expect("write watched");
let mut state = WatchState {
last_size: 6,
last_inode: get_inode(&watched).unwrap_or(0),
};
let event = Event {
kind: EventKind::Remove(notify::event::RemoveKind::File),
paths: vec![watched.clone()],
attrs: Default::default(),
};
let result = process_event(event, &watched, &mut state);
assert!(
matches!(result, Some(FileEvent::Removed)),
"Remove for watched path should emit Removed"
);
}
#[test] #[test]
fn test_watcher_append() { fn test_watcher_append() {
let dir = tempfile::tempdir().expect("create temp dir"); let dir = tempfile::tempdir().expect("create temp dir");
@@ -232,5 +292,19 @@ mod tests {
let d = FileEvent::Rotated { new_inode: 42 }; let d = FileEvent::Rotated { new_inode: 42 };
assert_ne!(a, d); assert_ne!(a, d);
let e1 = FileEvent::WatcherError {
message: "io error".into(),
};
let e2 = FileEvent::WatcherError {
message: "io error".into(),
};
assert_eq!(e1, e2);
assert_ne!(
e1,
FileEvent::WatcherError {
message: "other".into()
}
);
} }
} }

View File

@@ -6,7 +6,7 @@ use log_viewer_core::io::progressive_reader::{
IndexerMessage, ProgressiveFileReader, VisualHeightIndex, compute_line_visual_height, IndexerMessage, ProgressiveFileReader, VisualHeightIndex, compute_line_visual_height,
spawn_indexer, spawn_indexer,
}; };
use log_viewer_core::io::wrap::{format_json_line, wrap_line_chars}; use log_viewer_core::io::wrap::{format_json_line, wrap_line_chars, MAX_WRAP_INPUT_LEN};
use log_viewer_core::types::LogLevel; use log_viewer_core::types::LogLevel;
use log_viewer_core::watcher::file_watcher::{FileEvent, FileWatcher}; use log_viewer_core::watcher::file_watcher::{FileEvent, FileWatcher};
@@ -201,12 +201,33 @@ impl App {
/// Compute a single line's viewport entry (wrapped rows + level + height). /// Compute a single line's viewport entry (wrapped rows + level + height).
fn compute_line_entry(&self, line: usize, width: usize) -> ViewportEntry { fn compute_line_entry(&self, line: usize, width: usize) -> ViewportEntry {
let raw = self.get_line(line).unwrap_or_default(); let raw = self.get_line(line).unwrap_or_default();
// Guard 1: oversized raw input — skip detect_level and JSON formatting
// to avoid O(n) parsing overhead on huge lines.
if raw.len() > MAX_WRAP_INPUT_LEN {
return ViewportEntry {
wrapped_rows: vec![truncate_to_columns(&raw, width)],
level: None,
visual_height: 1,
};
}
let level = log_viewer_core::parser::level::detect_level(&raw); let level = log_viewer_core::parser::level::detect_level(&raw);
let display_text = if self.json_format { let display_text = if self.json_format {
format_json_line(&raw) format_json_line(&raw)
} else { } else {
raw raw
}; };
// Guard 2: JSON pretty-printing may expand a line beyond the limit.
if display_text.len() > MAX_WRAP_INPUT_LEN {
return ViewportEntry {
wrapped_rows: vec![truncate_to_columns(&display_text, width)],
level,
visual_height: 1,
};
}
let mut wrapped = Vec::new(); let mut wrapped = Vec::new();
for sub_line in display_text.split('\n') { for sub_line in display_text.split('\n') {
wrapped.extend(wrap_line_chars(sub_line, width)); wrapped.extend(wrap_line_chars(sub_line, width));
@@ -222,11 +243,23 @@ impl App {
/// Compute visual height for a single line without storing it. /// Compute visual height for a single line without storing it.
fn compute_visual_height(&self, line: usize, width: usize) -> usize { fn compute_visual_height(&self, line: usize, width: usize) -> usize {
let raw = self.get_line(line).unwrap_or_default(); let raw = self.get_line(line).unwrap_or_default();
// Guard 1: oversized raw input.
if raw.len() > MAX_WRAP_INPUT_LEN {
return 1;
}
let display_text = if self.json_format { let display_text = if self.json_format {
format_json_line(&raw) format_json_line(&raw)
} else { } else {
raw raw
}; };
// Guard 2: post-format expansion.
if display_text.len() > MAX_WRAP_INPUT_LEN {
return 1;
}
let mut height = 0; let mut height = 0;
for sub_line in display_text.split('\n') { for sub_line in display_text.split('\n') {
height += wrap_line_chars(sub_line, width).len(); height += wrap_line_chars(sub_line, width).len();
@@ -246,7 +279,6 @@ impl App {
/// Returns (start_logical, offset_in_line) for rendering. /// Returns (start_logical, offset_in_line) for rendering.
pub(crate) fn ensure_viewport_cache(&mut self, width: usize) -> (usize, usize) { pub(crate) fn ensure_viewport_cache(&mut self, width: usize) -> (usize, usize) {
let viewport_height = self.content_height as usize; let viewport_height = self.content_height as usize;
let v_offset = self.v_offset;
if !self.is_loaded() || width == 0 || viewport_height == 0 { if !self.is_loaded() || width == 0 || viewport_height == 0 {
return (0, 0); return (0, 0);
@@ -268,7 +300,7 @@ impl App {
// Find start logical line from v_offset // Find start logical line from v_offset
let (start_logical, offset_in_line) = if self.is_loading() { let (start_logical, offset_in_line) = if self.is_loading() {
(v_offset.min(self.total_lines().saturating_sub(1)), self.v_sub_offset) (self.v_offset.min(self.total_lines().saturating_sub(1)), self.v_sub_offset)
} else { } else {
self.find_logical_line_at_visual_row(self.v_offset, width) self.find_logical_line_at_visual_row(self.v_offset, width)
}; };
@@ -539,12 +571,65 @@ impl App {
// ── Key handling ──────────────────────────────────────────────── // ── Key handling ────────────────────────────────────────────────
pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) { pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) {
use crossterm::event::KeyEventKind;
let should_handle = match key.kind {
KeyEventKind::Press => true,
KeyEventKind::Repeat => self.is_repeatable_key(&key),
KeyEventKind::Release => false,
};
if !should_handle {
return;
}
match self.mode { match self.mode {
AppMode::Normal => self.handle_normal_key(key), AppMode::Normal => self.handle_normal_key(key),
AppMode::Settings => self.handle_settings_key(key), AppMode::Settings => self.handle_settings_key(key),
} }
} }
/// Keys that should auto-repeat when held (scroll/navigation only).
fn is_repeatable_key(&self, key: &crossterm::event::KeyEvent) -> bool {
use crossterm::event::{KeyCode, KeyModifiers};
let plain = key.modifiers.is_empty();
let ctrl = key.modifiers == KeyModifiers::CONTROL;
match self.mode {
AppMode::Normal => {
(plain
&& matches!(
key.code,
KeyCode::Char('j')
| KeyCode::Down
| KeyCode::Char('k')
| KeyCode::Up
| KeyCode::PageDown
| KeyCode::PageUp
))
|| (ctrl
&& matches!(
key.code,
KeyCode::Char('d')
| KeyCode::Char('u')
| KeyCode::Char('f')
| KeyCode::Char('b')
))
}
AppMode::Settings => plain
&& matches!(
key.code,
KeyCode::Char('j')
| KeyCode::Down
| KeyCode::Char('k')
| KeyCode::Up
| KeyCode::Left
| KeyCode::Right
),
}
}
fn handle_normal_key(&mut self, key: crossterm::event::KeyEvent) { fn handle_normal_key(&mut self, key: crossterm::event::KeyEvent) {
use crossterm::event::{KeyCode, KeyModifiers}; use crossterm::event::{KeyCode, KeyModifiers};
@@ -607,12 +692,15 @@ impl App {
self.json_format = !self.json_format; self.json_format = !self.json_format;
self.viewport_cache.invalidate(); self.viewport_cache.invalidate();
let width = self.viewport_cache.width; let width = self.viewport_cache.width;
let (new_offset, new_sub) = self.rebase_offset_for_invalidate();
if let AppLoadingState::Ready { reader } = &mut self.loading_state { if let AppLoadingState::Ready { reader } = &mut self.loading_state {
reader.invalidate_visual_height_index(); reader.invalidate_visual_height_index();
if width > 0 { if width > 0 {
reader.start_visual_height_rebuild(width, self.json_format); reader.start_visual_height_rebuild(width, self.json_format);
} }
} }
self.v_offset = new_offset;
self.v_sub_offset = new_sub;
self.last_g_press = None; self.last_g_press = None;
} }
KeyCode::Char('s') | KeyCode::Char('S') KeyCode::Char('s') | KeyCode::Char('S')
@@ -669,7 +757,7 @@ impl App {
if forward { if forward {
(p + 1) % colors.len() (p + 1) % colors.len()
} else { } else {
p.saturating_sub(1).min(colors.len() - 1) if p == 0 { colors.len() - 1 } else { p - 1 }
} }
} }
None => { None => {
@@ -771,6 +859,25 @@ impl App {
} }
} }
/// Compute the rebased offset pair (logical_line, sub_row) from the
/// current visual-row `v_offset`. Returns `(v_offset, v_sub_offset)`
/// suitable for the no-VHI fallback scrolling path.
///
/// MUST be called before borrowing `&mut self.loading_state` for
/// `invalidate_visual_height_index`, because it reads VHI through
/// `&self`.
fn rebase_offset_for_invalidate(&self) -> (usize, usize) {
if self.get_visual_height_index().is_some() {
let top_visual = self.v_offset;
let top_line = self.visual_row_to_logical_row(top_visual);
let line_first_visual = self.cursor_to_first_visual_row(top_line);
let sub = top_visual.saturating_sub(line_first_visual);
(top_line, sub)
} else {
(self.v_offset, self.v_sub_offset)
}
}
fn ensure_visual_height_index(&mut self, width: usize) { fn ensure_visual_height_index(&mut self, width: usize) {
let needs_rebuild = match self.get_visual_height_index() { let needs_rebuild = match self.get_visual_height_index() {
Some(idx) => !idx.is_valid_for(self.json_format, width), Some(idx) => !idx.is_valid_for(self.json_format, width),
@@ -778,10 +885,13 @@ impl App {
}; };
if needs_rebuild { if needs_rebuild {
let (new_offset, new_sub) = self.rebase_offset_for_invalidate();
if let AppLoadingState::Ready { reader } = &mut self.loading_state { if let AppLoadingState::Ready { reader } = &mut self.loading_state {
reader.invalidate_visual_height_index(); reader.invalidate_visual_height_index();
reader.start_visual_height_rebuild(width, self.json_format); reader.start_visual_height_rebuild(width, self.json_format);
} }
self.v_offset = new_offset;
self.v_sub_offset = new_sub;
} }
} }
@@ -857,14 +967,17 @@ impl App {
FileEvent::Removed => { FileEvent::Removed => {
self.loading_state = AppLoadingState::Error("File has been deleted".into()); self.loading_state = AppLoadingState::Error("File has been deleted".into());
} }
FileEvent::WatcherError { message: _ } => {}
} }
} }
} }
fn handle_file_appended(&mut self) { fn handle_file_appended(&mut self) {
let width = self.get_content_width(); let width = self.get_content_width();
let rebased = self.rebase_offset_for_invalidate();
match &mut self.loading_state { match &mut self.loading_state {
AppLoadingState::Ready { reader } => { AppLoadingState::Ready { reader } => {
let old_reader_line_count = reader.line_count();
let status = reader.update_for_append(); let status = reader.update_for_append();
match status { match status {
Ok( Ok(
@@ -883,13 +996,24 @@ impl App {
}; };
let new_line_count = reader.line_count(); let new_line_count = reader.line_count();
if can_extend && new_line_count > old_line_count { if can_extend && old_line_count == old_reader_line_count {
if let log_viewer_core::io::progressive_reader::ReaderState::Ready { if let log_viewer_core::io::progressive_reader::ReaderState::Ready {
visual_height_index: Some(index), visual_height_index: Some(index),
reader: fr, reader: fr,
} = &mut reader.state } = &mut reader.state
{ {
let mut new_heights = Vec::with_capacity(new_line_count - old_line_count); if old_line_count > 0 {
let last_old_line_text =
fr.get_line(old_line_count - 1).unwrap_or("");
let new_h = compute_line_visual_height(
last_old_line_text,
width,
self.json_format,
);
index.replace_last_line_height(new_h);
}
let mut new_heights =
Vec::with_capacity(new_line_count.saturating_sub(old_line_count));
for i in old_line_count..new_line_count { for i in old_line_count..new_line_count {
let line_text = fr.get_line(i).unwrap_or(""); let line_text = fr.get_line(i).unwrap_or("");
new_heights.push(compute_line_visual_height( new_heights.push(compute_line_visual_height(
@@ -901,6 +1025,9 @@ impl App {
index.extend_from_heights(&new_heights); index.extend_from_heights(&new_heights);
} }
} else { } else {
let (new_offset, new_sub) = rebased;
self.v_offset = new_offset;
self.v_sub_offset = new_sub;
reader.invalidate_visual_height_index(); reader.invalidate_visual_height_index();
reader.start_visual_height_rebuild(width, self.json_format); reader.start_visual_height_rebuild(width, self.json_format);
} }
@@ -909,9 +1036,11 @@ impl App {
} }
Ok(log_viewer_core::io::file_reader::AppendStatus::Reloaded) => { Ok(log_viewer_core::io::file_reader::AppendStatus::Reloaded) => {
let _ = reader.save_cache(); let _ = reader.save_cache();
let (new_offset, _new_sub) = rebased;
reader.invalidate_visual_height_index(); reader.invalidate_visual_height_index();
reader.start_visual_height_rebuild(width, self.json_format); reader.start_visual_height_rebuild(width, self.json_format);
self.cursor_line = self.cursor_line.min(self.total_lines().saturating_sub(1)); self.cursor_line = self.cursor_line.min(self.total_lines().saturating_sub(1));
self.v_offset = new_offset;
self.v_sub_offset = 0; self.v_sub_offset = 0;
self.viewport_cache.invalidate(); self.viewport_cache.invalidate();
self.clamp_v_offset(); self.clamp_v_offset();
@@ -928,12 +1057,14 @@ impl App {
fn reload_ready_reader(&mut self) { fn reload_ready_reader(&mut self) {
let width = self.get_content_width(); let width = self.get_content_width();
let (new_offset, _new_sub) = self.rebase_offset_for_invalidate();
if let AppLoadingState::Ready { reader } = &mut self.loading_state { if let AppLoadingState::Ready { reader } = &mut self.loading_state {
let _ = reader.reload(); let _ = reader.reload();
let _ = reader.save_cache(); let _ = reader.save_cache();
reader.invalidate_visual_height_index(); reader.invalidate_visual_height_index();
reader.start_visual_height_rebuild(width, self.json_format); reader.start_visual_height_rebuild(width, self.json_format);
self.cursor_line = self.cursor_line.min(self.total_lines().saturating_sub(1)); self.cursor_line = self.cursor_line.min(self.total_lines().saturating_sub(1));
self.v_offset = new_offset;
self.v_sub_offset = 0; self.v_sub_offset = 0;
self.viewport_cache.invalidate(); self.viewport_cache.invalidate();
self.clamp_v_offset(); self.clamp_v_offset();
@@ -965,10 +1096,23 @@ impl App {
} = &mut reader.state } = &mut reader.state
{ {
*visual_height_index = Some(index); *visual_height_index = Some(index);
}
}
}
// Recalibrate v_offset from logical → visual-row now that VHI is available.
// Must be outside the `reader` borrow above.
if self.get_visual_height_index().is_some() {
let logical_top = self.v_offset.min(self.total_lines().saturating_sub(1));
let sub = self.v_sub_offset;
let first_visual = self.cursor_to_first_visual_row(logical_top);
let line_height = self
.get_visual_height_index()
.map_or(1, |idx| idx.visual_height_of_line(logical_top));
self.v_offset = first_visual.saturating_add(sub.min(line_height.saturating_sub(1)));
self.v_sub_offset = 0;
self.clamp_v_offset();
self.viewport_cache.invalidate(); self.viewport_cache.invalidate();
} }
}
}
// Poll main indexer (Loading state) // Poll main indexer (Loading state)
let old_state = std::mem::replace(&mut self.loading_state, AppLoadingState::Empty); let old_state = std::mem::replace(&mut self.loading_state, AppLoadingState::Empty);
@@ -1013,9 +1157,12 @@ impl App {
// Gutter width changes (~N → N) shift content_width, so any // Gutter width changes (~N → N) shift content_width, so any
// VisualHeightIndex built with the old width is stale. // VisualHeightIndex built with the old width is stale.
let (new_offset, new_sub) = self.rebase_offset_for_invalidate();
if let AppLoadingState::Ready { reader } = &mut self.loading_state { if let AppLoadingState::Ready { reader } = &mut self.loading_state {
reader.invalidate_visual_height_index(); reader.invalidate_visual_height_index();
} }
self.v_offset = new_offset;
self.v_sub_offset = new_sub;
if self.reload_after_loading { if self.reload_after_loading {
self.reload_after_loading = false; self.reload_after_loading = false;
@@ -1040,6 +1187,13 @@ impl App {
} }
} }
fn truncate_to_columns(s: &str, max_cols: usize) -> String {
if max_cols == 0 || s.is_empty() {
return String::new();
}
wrap_line_chars(s, max_cols).into_iter().next().unwrap_or_default()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1782,6 +1936,14 @@ plain text line
app.handle_key(make_key(crossterm::event::KeyCode::Left)); app.handle_key(make_key(crossterm::event::KeyCode::Left));
assert_eq!(app.settings_draft.error, "red"); assert_eq!(app.settings_draft.error, "red");
// backward wrap: red (index 0) → white (index 7)
app.handle_key(make_key(crossterm::event::KeyCode::Left));
assert_eq!(app.settings_draft.error, "white");
// forward wrap: white (index 7) → red (index 0)
app.handle_key(make_key(crossterm::event::KeyCode::Right));
assert_eq!(app.settings_draft.error, "red");
} }
#[test] #[test]
@@ -2472,8 +2634,46 @@ plain text line
assert!(result.is_ok()); assert!(result.is_ok());
} }
/// Regression test for issue #24:
/// ensure_viewport_cache must use the updated self.v_offset after params_changed
/// recalculates it, not a stale local captured before the change block.
#[test]
fn test_loading_viewport_cache_uses_updated_v_offset_on_params_changed() {
let content: String = (0..200).map(|i| format!("line{i}\n")).collect();
let path = make_temp_file(&content);
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
app.load_file(path.to_str().unwrap()).unwrap();
assert!(app.is_loading(), "should be in Loading state");
app.content_height = 10;
app.ensure_viewport_cache(80);
app.v_offset = 90;
app.cursor_line = 100;
let new_width = 40;
app.ensure_viewport_cache(new_width);
let recomputed_offset = app.v_offset;
assert_ne!(
recomputed_offset, 90,
"v_offset should have been recalculated by params_changed block, still 90"
);
assert_eq!(
app.viewport_cache.logical_start, recomputed_offset.min(app.total_lines().saturating_sub(1)),
"logical_start should match the updated v_offset"
);
});
cleanup(&path);
assert!(result.is_ok());
}
fn install_vhi(app: &mut App, heights: &[usize]) { fn install_vhi(app: &mut App, heights: &[usize]) {
let vhi = VisualHeightIndex::build(heights); let width = app.get_content_width();
let json_format = app.json_format;
let vhi = VisualHeightIndex::build(heights).with_params(json_format, width);
if let AppLoadingState::Ready { reader } = &mut app.loading_state { if let AppLoadingState::Ready { reader } = &mut app.loading_state {
if let log_viewer_core::io::progressive_reader::ReaderState::Ready { if let log_viewer_core::io::progressive_reader::ReaderState::Ready {
visual_height_index, visual_height_index,
@@ -2800,4 +3000,560 @@ plain text line
cleanup(&path); cleanup(&path);
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test]
fn test_append_no_trailing_newline_updates_last_line_height() {
let path = make_temp_file("abc");
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
assert_eq!(app.total_lines(), 1);
app.content_width = 5;
install_vhi(&mut app, &[1usize]);
{
let vhi = app.get_visual_height_index().unwrap();
assert_eq!(vhi.visual_height_of_line(0), 1);
assert_eq!(vhi.total_visual_rows(), 1);
}
// Append content that extends line 0 (no trailing newline before)
// "abc" + "defgh\n" = "abcdefgh\n" → 8 chars in width 5 → wraps to 2 visual rows
// old_total=1, old_had_trailing=false → starts_new_line=false
// new_has_trailing=true, new_newlines=1 → added = 1-1 = 0
// Still 1 logical line, but line 0 text changed from "abc" to "abcdefgh"
{
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
f.write_all(b"defgh\n").unwrap();
}
std::thread::sleep(std::time::Duration::from_millis(500));
app.poll_file_watcher();
assert_eq!(app.total_lines(), 1,
"\"abcdefgh\\n\" has trailing newline → 1 logical line");
let vhi = app.get_visual_height_index().expect("VHI should still exist after append");
assert_eq!(vhi.visual_height_of_line(0), 2,
"line 0 height should be updated from 1 to 2 after extending 'abcdefgh' in width 5");
assert_eq!(vhi.total_visual_rows(), 2);
assert_eq!(vhi.cursor_to_first_visual_row(0), 0);
cleanup(&path);
});
assert!(result.is_ok());
}
// ── Issue #23: KeyEventKind filtering ───────────────────────────
fn make_key_with_kind(
code: crossterm::event::KeyCode,
modifiers: crossterm::event::KeyModifiers,
kind: crossterm::event::KeyEventKind,
) -> crossterm::event::KeyEvent {
crossterm::event::KeyEvent::new_with_kind(code, modifiers, kind)
}
#[test]
fn issue23_release_quit_ignored() {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
let mut app = App::new();
let release_q = make_key_with_kind(KeyCode::Char('q'), KeyModifiers::NONE, KeyEventKind::Release);
app.handle_key(release_q);
assert!(!app.should_quit, "Release+q must NOT quit");
}
#[test]
fn issue23_release_scroll_ignored() {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
let path = make_temp_file("a\nb\nc\n");
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
let release_j = make_key_with_kind(KeyCode::Char('j'), KeyModifiers::NONE, KeyEventKind::Release);
app.handle_key(release_j);
assert_eq!(app.cursor_line, 0, "Release+j must NOT scroll");
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn issue23_repeat_quit_ignored() {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
let mut app = App::new();
let repeat_q = make_key_with_kind(KeyCode::Char('q'), KeyModifiers::NONE, KeyEventKind::Repeat);
app.handle_key(repeat_q);
assert!(!app.should_quit, "Repeat+q must NOT quit");
}
#[test]
fn issue23_repeat_j_passes() {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
let path = make_temp_file("a\nb\nc\n");
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
let repeat_j = make_key_with_kind(KeyCode::Char('j'), KeyModifiers::NONE, KeyEventKind::Repeat);
app.handle_key(repeat_j);
assert_eq!(app.cursor_line, 1, "Repeat+j must scroll");
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn issue23_repeat_ctrl_d_passes() {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
let path = make_temp_file("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n");
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
app.content_height = 5;
load_file_ready(&mut app, &path);
install_vhi(&mut app, &[1usize; 10]);
let repeat_ctrl_d = make_key_with_kind(
KeyCode::Char('d'), KeyModifiers::CONTROL, KeyEventKind::Repeat,
);
app.handle_key(repeat_ctrl_d);
assert!(app.cursor_line > 0, "Repeat+Ctrl+d must scroll half page");
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn issue23_repeat_ctrl_d_without_ctrl_ignored() {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
let path = make_temp_file("a\nb\nc\n");
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
let repeat_plain_d = make_key_with_kind(
KeyCode::Char('d'), KeyModifiers::NONE, KeyEventKind::Repeat,
);
app.handle_key(repeat_plain_d);
assert_eq!(app.cursor_line, 0, "Repeat+plain d must NOT scroll");
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn issue23_repeat_g_ignored() {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
let path = make_temp_file("a\nb\nc\n");
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
let repeat_g = make_key_with_kind(KeyCode::Char('g'), KeyModifiers::NONE, KeyEventKind::Repeat);
app.handle_key(repeat_g);
assert_eq!(app.cursor_line, 0, "Repeat+g must NOT jump");
assert!(app.last_g_press.is_none(), "Repeat+g must not set last_g_press");
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn issue23_settings_repeat_left_right_passes() {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
let mut app = App::new();
enter_settings(&mut app);
assert_eq!(app.mode, AppMode::Settings);
let repeat_right = make_key_with_kind(KeyCode::Right, KeyModifiers::NONE, KeyEventKind::Repeat);
app.handle_key(repeat_right);
app.handle_key(make_key(KeyCode::Enter));
assert_eq!(app.mode, AppMode::Normal, "Repeat Right in Settings should work then Enter closes");
}
#[test]
fn issue23_settings_repeat_enter_ignored() {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
let mut app = App::new();
enter_settings(&mut app);
let repeat_enter = make_key_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Repeat);
app.handle_key(repeat_enter);
assert_eq!(app.mode, AppMode::Settings, "Repeat+Enter must NOT close settings");
}
#[test]
fn test_append_no_trailing_newline_no_new_lines_only_height_change() {
let path = make_temp_file("abc");
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
assert_eq!(app.total_lines(), 1);
app.content_width = 5;
install_vhi(&mut app, &[1usize]);
// Append without adding any new line — just extends line 0
// "abc" + "def" = "abcdef" → 6 chars in width 5 → wraps to 2 visual rows, still 1 logical line
{
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
f.write_all(b"def").unwrap();
}
std::thread::sleep(std::time::Duration::from_millis(500));
app.poll_file_watcher();
assert_eq!(app.total_lines(), 1,
"no new logical line should be added");
let vhi = app.get_visual_height_index().expect("VHI should still exist");
assert_eq!(vhi.visual_height_of_line(0), 2,
"line 0 height should update even when no new lines added");
assert_eq!(vhi.total_visual_rows(), 2);
cleanup(&path);
});
assert!(result.is_ok());
}
// ── Issue #25 regression tests ──────────────────────────────────
// Ready state without VHI must keep v_offset self-consistent.
#[test]
fn test_rebase_offset_converts_visual_to_logical_with_sub() {
let content = "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n";
let path = make_temp_file(content);
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
app.content_height = 10;
// 10 lines × 3 visual rows each = 30 visual rows
install_vhi(&mut app, &[3usize; 10]);
// v_offset=7 → visual row 7 is inside line2 (rows 6-8), sub=1
app.v_offset = 7;
app.v_sub_offset = 0;
let (logical, sub) = app.rebase_offset_for_invalidate();
assert_eq!(logical, 2, "visual row 7 → logical line 2 (rows 6-8)");
assert_eq!(sub, 1, "visual row 7 is sub-row 1 within line 2");
// v_offset=0 → line 0, sub=0
app.v_offset = 0;
let (logical, sub) = app.rebase_offset_for_invalidate();
assert_eq!(logical, 0);
assert_eq!(sub, 0);
// v_offset=5 → line1 (rows 3-5), sub=2
app.v_offset = 5;
let (logical, sub) = app.rebase_offset_for_invalidate();
assert_eq!(logical, 1, "visual row 5 → logical line 1 (rows 3-5)");
assert_eq!(sub, 2);
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn test_rebase_offset_passthrough_when_no_vhi() {
let content = "line0\nline1\nline2\n";
let path = make_temp_file(content);
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
// No VHI installed — rebase should return current values unchanged
app.v_offset = 42;
app.v_sub_offset = 3;
let (logical, sub) = app.rebase_offset_for_invalidate();
assert_eq!(logical, 42);
assert_eq!(sub, 3);
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn test_tab_toggle_rebases_offset_before_invalidate() {
let content = "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n";
let path = make_temp_file(content);
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
app.content_height = 10;
app.content_width = 20;
install_vhi(&mut app, &[3usize; 10]);
// Scroll to visual row 7 → line2 sub-row 1
app.v_offset = 7;
app.cursor_line = 2;
app.v_sub_offset = 0;
// Simulate Tab toggle: rebase, then invalidate
let rebased = app.rebase_offset_for_invalidate();
let (new_offset, new_sub) = rebased;
app.v_offset = new_offset;
app.v_sub_offset = new_sub;
assert_eq!(app.v_offset, 2, "v_offset should be logical line 2 after rebase");
assert_eq!(app.v_sub_offset, 1, "v_sub_offset should preserve sub-row 1");
// Key invariant: v_offset is now a valid logical line number,
// not a stale visual-row offset that would cause a jump.
// Before the fix, v_offset would still be 7 (visual) treated as logical → jump.
assert!(
app.v_offset < app.total_lines(),
"v_offset ({}) must be a valid logical line index < {}",
app.v_offset, app.total_lines()
);
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn test_vhi_rebuild_recalibrates_from_viewport_top() {
let content = "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n";
let path = make_temp_file(content);
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
app.content_height = 10;
app.content_width = 20;
// Install VHI with varying heights
install_vhi(&mut app, &[2, 4, 3, 1, 2, 5, 1, 3, 2, 1]);
// Scroll to visual row 8 → line2 (rows 6-8), sub=2
app.v_offset = 8;
app.cursor_line = 2;
app.v_sub_offset = 0;
// Rebase (simulates invalidate)
let (logical_top, sub) = app.rebase_offset_for_invalidate();
assert_eq!(logical_top, 2);
assert_eq!(sub, 2);
// Simulate what happens: v_offset becomes logical, VHI cleared
app.v_offset = logical_top;
app.v_sub_offset = sub;
// Now simulate VHI rebuild completion — the recalibration block
// Install a fresh VHI (same heights, simulates rebuild result)
install_vhi(&mut app, &[2, 4, 3, 1, 2, 5, 1, 3, 2, 1]);
// The recalibration logic from poll_background_indexer
let logical_top = app.v_offset.min(app.total_lines().saturating_sub(1));
let sub = app.v_sub_offset;
let first_visual = app.cursor_to_first_visual_row(logical_top);
let line_height = app
.get_visual_height_index()
.map_or(1, |idx| idx.visual_height_of_line(logical_top));
app.v_offset = first_visual.saturating_add(sub.min(line_height.saturating_sub(1)));
app.v_sub_offset = 0;
// line2 starts at visual row 6, sub was 2 → visual row 8
assert_eq!(app.v_offset, 8, "v_offset should map back to visual row 8");
assert_eq!(app.v_sub_offset, 0, "v_sub_offset should be 0 after recalibration");
// Scrolling should work normally with VHI
app.scroll_down_line();
assert_eq!(app.v_offset, 9, "scroll down should advance visual row");
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn test_vhi_rebuild_clamps_sub_to_new_line_height() {
let content = "line0\nline1\nline2\n";
let path = make_temp_file(content);
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
app.content_height = 24;
app.content_width = 20;
// Line heights: line0=1, line1=5 (wraps), line2=1
install_vhi(&mut app, &[1, 5, 1]);
// Position at line1 sub-row 4 (last sub-row of 5-row line)
app.v_offset = 5;
app.cursor_line = 1;
app.v_sub_offset = 0;
// Rebase
let (logical_top, sub) = app.rebase_offset_for_invalidate();
assert_eq!(logical_top, 1, "visual row 5 → line 1 (rows 1-5)");
assert_eq!(sub, 4, "sub-row 4 within line 1");
app.v_offset = logical_top;
app.v_sub_offset = sub;
// Simulate VHI rebuild with SHRUNK line height (e.g., JSON toggle)
// line1 now only 2 visual rows instead of 5
install_vhi(&mut app, &[1, 2, 1]);
// Recalibration should clamp sub=4 to new height-1=1
let logical_top = app.v_offset.min(app.total_lines().saturating_sub(1));
let sub = app.v_sub_offset;
let first_visual = app.cursor_to_first_visual_row(logical_top);
let line_height = app
.get_visual_height_index()
.map_or(1, |idx| idx.visual_height_of_line(logical_top));
let clamped_sub = sub.min(line_height.saturating_sub(1));
app.v_offset = first_visual.saturating_add(clamped_sub);
app.v_sub_offset = 0;
// line1 starts at visual row 1, clamped sub=1 → visual row 2
assert_eq!(app.v_offset, 2, "v_offset should be clamped to visual row 2 (line1 row 1 of 2)");
assert_eq!(app.v_sub_offset, 0);
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn test_loading_to_ready_rebases_visual_offset() {
let content = "line0\nline1\nline2\nline3\nline4\n";
let path = make_temp_file(content);
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
app.content_height = 10;
// Install VHI: 2 rows per line
install_vhi(&mut app, &[2usize; 5]);
// Scroll to visual row 3 → line1 sub-row 1
app.v_offset = 3;
app.cursor_line = 1;
app.v_sub_offset = 0;
// Simulate Loading→Ready invalidation rebase
let (new_offset, new_sub) = app.rebase_offset_for_invalidate();
assert_eq!(new_offset, 1, "visual row 3 → line 1");
assert_eq!(new_sub, 1, "sub-row 1 within line 1");
// Apply rebased values and clear VHI (simulate invalidate)
app.v_offset = new_offset;
app.v_sub_offset = new_sub;
if let AppLoadingState::Ready { reader } = &mut app.loading_state {
reader.invalidate_visual_height_index();
}
// VHI is now None → scroll_down_line uses else branch (v_sub_offset path)
// "line1" at width=80 → compute_visual_height=1, sub=1+1 >= 1 → advance
app.scroll_down_line();
assert_eq!(app.v_offset, 2, "should advance to line 2 (line1 height=1, sub overflow)");
assert_eq!(app.v_sub_offset, 0);
cleanup(&path);
});
assert!(result.is_ok());
}
#[test]
fn test_compute_line_entry_oversized_raw_guard() {
let long_line = "x".repeat(MAX_WRAP_INPUT_LEN + 1);
let path = make_temp_file(&format!("short\n{}\n", long_line));
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
assert!(app.is_loaded());
let entry = app.compute_line_entry(1, 80);
assert_eq!(entry.visual_height, 1, "oversized line should have height 1");
assert!(entry.level.is_none(), "oversized raw line should skip detect_level");
assert_eq!(entry.wrapped_rows.len(), 1);
assert!(
entry.wrapped_rows[0].len() <= 80,
"preview should be bounded to width"
);
let short_entry = app.compute_line_entry(0, 80);
assert_eq!(short_entry.visual_height, 1);
assert!(short_entry.level.is_none(), "'short' has no log level");
});
cleanup(&path);
assert!(result.is_ok());
}
#[test]
fn test_compute_visual_height_oversized_raw_guard() {
let long_line = "a".repeat(MAX_WRAP_INPUT_LEN + 100);
let path = make_temp_file(&format!("hi\n{}\n", long_line));
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
assert!(app.is_loaded());
assert_eq!(app.compute_visual_height(0, 80), 1, "normal line height=1");
assert_eq!(app.compute_visual_height(1, 80), 1, "oversized line height=1");
});
cleanup(&path);
assert!(result.is_ok());
}
#[test]
fn test_compute_line_entry_post_format_guard() {
// Build a JSON object whose raw form is just under MAX_WRAP_INPUT_LEN
// but whose pretty-printed form (with added spaces around ':') exceeds it.
let inner = "x".repeat(MAX_WRAP_INPUT_LEN - 11);
let json_line = format!(r#"{{"msg":"{}"}}"#, inner);
assert!(
json_line.len() < MAX_WRAP_INPUT_LEN,
"raw JSON should be under limit: got {}",
json_line.len()
);
let path = make_temp_file(&format!("{}\n", json_line));
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
app.json_format = true;
let entry = app.compute_line_entry(0, 80);
assert_eq!(entry.visual_height, 1, "post-format oversized should have height 1");
assert_eq!(entry.wrapped_rows.len(), 1);
});
cleanup(&path);
assert!(result.is_ok());
}
#[test]
fn test_truncate_to_columns_basic() {
assert_eq!(truncate_to_columns("hello world", 5), "hello");
assert_eq!(truncate_to_columns("hi", 80), "hi");
assert_eq!(truncate_to_columns("", 80), "");
assert_eq!(truncate_to_columns("abc", 0), "");
}
#[test]
fn test_truncate_to_columns_cjk() {
// Each CJK char is 2 columns wide
assert_eq!(truncate_to_columns("你好世界", 3), "");
assert_eq!(truncate_to_columns("你好世界", 4), "你好");
}
#[test]
fn test_truncate_to_columns_tab() {
// Tab expands to 4 spaces
assert_eq!(truncate_to_columns("a\tb", 3), "a");
}
} }