Skip to content

Commit 97c0481

Browse files
committed
Bugfix: Fix 3 bugs found by new unit tests
- Fix `LineIndexBackend::search()` using byte offsets instead of UTF-16 code units for `SearchMatch.column` and `.length` — search highlights after emoji/CJK landed in wrong positions - Fix `parse_smbutil_output()` using `split_whitespace()` which broke share names with spaces (like "Time Machine", "My Documents" common on Synology/QNAP). Now uses keyword-based column detection via `rfind` - Fix race condition in `manual_servers.rs` where concurrent `add_manual_server()` calls could silently lose entries. Added `STORE_LOCK` mutex to protect read-modify-write cycle - Add 46 unit tests across 5 areas: file viewer UTF-8 (17), listing stats (7), smbutil parser (10), indexing lifecycle (5), concurrent writes (7) - Update `network/CLAUDE.md` with concurrency strategy for persistence stores
1 parent 88c3f5b commit 97c0481

11 files changed

Lines changed: 1509 additions & 75 deletions

File tree

apps/desktop/src-tauri/src/file_system/listing/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,5 @@ mod hidden_files_test;
4141
mod operations_test;
4242
#[cfg(test)]
4343
mod sorting_test;
44+
#[cfg(test)]
45+
mod stats_test;
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
//! Tests for `get_listing_stats()` — total/visible counts, sizes, and selection sums.
2+
3+
use super::caching::{CachedListing, LISTING_CACHE};
4+
use super::operations::{get_listing_stats, list_directory_end};
5+
use super::sorting::DirectorySortMode;
6+
use super::{FileEntry, SortColumn, SortOrder};
7+
8+
/// Creates a test entry with configurable sizes.
9+
fn make_entry(name: &str, is_dir: bool, size: Option<u64>, physical_size: Option<u64>) -> FileEntry {
10+
let mut entry = FileEntry {
11+
size: if is_dir { None } else { size },
12+
physical_size: if is_dir { None } else { physical_size },
13+
recursive_size: if is_dir { size } else { None },
14+
recursive_physical_size: if is_dir { physical_size } else { None },
15+
modified_at: Some(1_700_000_000),
16+
created_at: Some(1_700_000_000),
17+
permissions: if is_dir { 0o755 } else { 0o644 },
18+
owner: "testuser".to_string(),
19+
group: "staff".to_string(),
20+
extended_metadata_loaded: true,
21+
..FileEntry::new(name.to_string(), format!("/{}", name), is_dir, false)
22+
};
23+
// Ensure directories don't have file-level size and vice versa
24+
if is_dir {
25+
entry.size = None;
26+
entry.physical_size = None;
27+
} else {
28+
entry.recursive_size = None;
29+
entry.recursive_physical_size = None;
30+
}
31+
entry
32+
}
33+
34+
/// Inserts entries into the listing cache and returns the listing ID.
35+
fn insert_test_listing(id: &str, entries: Vec<FileEntry>) -> String {
36+
let listing_id = id.to_string();
37+
let mut cache = LISTING_CACHE.write().unwrap();
38+
cache.insert(
39+
listing_id.clone(),
40+
CachedListing {
41+
volume_id: "test".to_string(),
42+
path: std::path::PathBuf::from("/"),
43+
entries,
44+
sort_by: SortColumn::Name,
45+
sort_order: SortOrder::Ascending,
46+
directory_sort_mode: DirectorySortMode::LikeFiles,
47+
sequence: std::sync::atomic::AtomicU64::new(0),
48+
},
49+
);
50+
listing_id
51+
}
52+
53+
// ============================================================================
54+
// Basic stats
55+
// ============================================================================
56+
57+
#[test]
58+
fn test_stats_mixed_files_and_dirs() {
59+
let entries = vec![
60+
make_entry("Documents", true, Some(50_000), Some(52_000)),
61+
make_entry("Photos", true, Some(100_000), Some(104_000)),
62+
make_entry("notes.txt", false, Some(1_024), Some(4_096)),
63+
make_entry("report.pdf", false, Some(2_048), Some(4_096)),
64+
make_entry("tiny.log", false, Some(10), Some(4_096)),
65+
];
66+
let listing_id = insert_test_listing("test-stats-mixed", entries);
67+
68+
let stats = get_listing_stats(&listing_id, true, None).unwrap();
69+
70+
list_directory_end(&listing_id);
71+
72+
assert_eq!(stats.total_dirs, 2);
73+
assert_eq!(stats.total_files, 3);
74+
assert_eq!(stats.total_size, 50_000 + 100_000 + 1_024 + 2_048 + 10);
75+
assert_eq!(stats.total_physical_size, 52_000 + 104_000 + 4_096 + 4_096 + 4_096);
76+
assert!(stats.selected_files.is_none());
77+
assert!(stats.selected_dirs.is_none());
78+
assert!(stats.selected_size.is_none());
79+
assert!(stats.selected_physical_size.is_none());
80+
}
81+
82+
// ============================================================================
83+
// Hidden file filtering
84+
// ============================================================================
85+
86+
#[test]
87+
fn test_stats_hidden_files_excluded() {
88+
let entries = vec![
89+
make_entry(".config", true, Some(5_000), Some(8_192)),
90+
make_entry(".bashrc", false, Some(512), Some(4_096)),
91+
make_entry("Documents", true, Some(50_000), Some(52_000)),
92+
make_entry("readme.md", false, Some(1_024), Some(4_096)),
93+
];
94+
let listing_id = insert_test_listing("test-stats-hidden-excluded", entries);
95+
96+
let stats_all = get_listing_stats(&listing_id, true, None).unwrap();
97+
let stats_visible = get_listing_stats(&listing_id, false, None).unwrap();
98+
99+
list_directory_end(&listing_id);
100+
101+
// With hidden: all 4 entries
102+
assert_eq!(stats_all.total_dirs, 2);
103+
assert_eq!(stats_all.total_files, 2);
104+
assert_eq!(stats_all.total_size, 5_000 + 512 + 50_000 + 1_024);
105+
106+
// Without hidden: only Documents + readme.md
107+
assert_eq!(stats_visible.total_dirs, 1);
108+
assert_eq!(stats_visible.total_files, 1);
109+
assert_eq!(stats_visible.total_size, 50_000 + 1_024);
110+
assert_eq!(stats_visible.total_physical_size, 52_000 + 4_096);
111+
}
112+
113+
// ============================================================================
114+
// Selection stats
115+
// ============================================================================
116+
117+
#[test]
118+
fn test_stats_with_selection() {
119+
let entries = vec![
120+
make_entry("Documents", true, Some(50_000), Some(52_000)),
121+
make_entry("Photos", true, Some(100_000), Some(104_000)),
122+
make_entry("notes.txt", false, Some(1_024), Some(4_096)),
123+
make_entry("report.pdf", false, Some(2_048), Some(4_096)),
124+
];
125+
let listing_id = insert_test_listing("test-stats-selection", entries);
126+
127+
// Select indices 0 (Documents) and 2 (notes.txt)
128+
let stats = get_listing_stats(&listing_id, true, Some(&[0, 2])).unwrap();
129+
130+
list_directory_end(&listing_id);
131+
132+
// Totals cover all entries
133+
assert_eq!(stats.total_dirs, 2);
134+
assert_eq!(stats.total_files, 2);
135+
136+
// Selection covers only the two selected entries
137+
assert_eq!(stats.selected_dirs, Some(1));
138+
assert_eq!(stats.selected_files, Some(1));
139+
assert_eq!(stats.selected_size, Some(50_000 + 1_024));
140+
assert_eq!(stats.selected_physical_size, Some(52_000 + 4_096));
141+
}
142+
143+
// ============================================================================
144+
// Edge cases
145+
// ============================================================================
146+
147+
#[test]
148+
fn test_stats_empty_directory() {
149+
let listing_id = insert_test_listing("test-stats-empty", vec![]);
150+
151+
let stats = get_listing_stats(&listing_id, true, None).unwrap();
152+
153+
list_directory_end(&listing_id);
154+
155+
assert_eq!(stats.total_dirs, 0);
156+
assert_eq!(stats.total_files, 0);
157+
assert_eq!(stats.total_size, 0);
158+
assert_eq!(stats.total_physical_size, 0);
159+
assert!(stats.selected_files.is_none());
160+
}
161+
162+
#[test]
163+
fn test_stats_all_hidden_without_hidden_flag() {
164+
let entries = vec![
165+
make_entry(".git", true, Some(200_000), Some(204_800)),
166+
make_entry(".gitignore", false, Some(256), Some(4_096)),
167+
make_entry(".env", false, Some(128), Some(4_096)),
168+
];
169+
let listing_id = insert_test_listing("test-stats-all-hidden", entries);
170+
171+
let stats = get_listing_stats(&listing_id, false, None).unwrap();
172+
173+
list_directory_end(&listing_id);
174+
175+
// Everything is hidden, so visible stats are all zero
176+
assert_eq!(stats.total_dirs, 0);
177+
assert_eq!(stats.total_files, 0);
178+
assert_eq!(stats.total_size, 0);
179+
assert_eq!(stats.total_physical_size, 0);
180+
}
181+
182+
#[test]
183+
fn test_stats_selection_with_out_of_bounds_index_is_ignored() {
184+
let entries = vec![make_entry("file.txt", false, Some(1_000), Some(4_096))];
185+
let listing_id = insert_test_listing("test-stats-oob-selection", entries);
186+
187+
// Index 0 is valid, index 99 is out of bounds
188+
let stats = get_listing_stats(&listing_id, true, Some(&[0, 99])).unwrap();
189+
190+
list_directory_end(&listing_id);
191+
192+
// Only the valid index should be counted
193+
assert_eq!(stats.selected_files, Some(1));
194+
assert_eq!(stats.selected_dirs, Some(0));
195+
assert_eq!(stats.selected_size, Some(1_000));
196+
}
197+
198+
#[test]
199+
fn test_stats_entries_without_sizes() {
200+
// Entries where size is None (e.g., network volumes that don't report sizes)
201+
let entries = vec![
202+
make_entry("remote_dir", true, None, None),
203+
make_entry("unknown.dat", false, None, None),
204+
];
205+
let listing_id = insert_test_listing("test-stats-no-sizes", entries);
206+
207+
let stats = get_listing_stats(&listing_id, true, Some(&[0, 1])).unwrap();
208+
209+
list_directory_end(&listing_id);
210+
211+
assert_eq!(stats.total_dirs, 1);
212+
assert_eq!(stats.total_files, 1);
213+
assert_eq!(stats.total_size, 0);
214+
assert_eq!(stats.total_physical_size, 0);
215+
assert_eq!(stats.selected_size, Some(0));
216+
assert_eq!(stats.selected_physical_size, Some(0));
217+
}

apps/desktop/src-tauri/src/file_viewer/byte_seek_test.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,3 +368,145 @@ fn search_caps_at_match_limit() {
368368

369369
cleanup(&dir);
370370
}
371+
372+
// ─── Multi-byte UTF-8 tests ────────────────────────────────────────────
373+
374+
#[test]
375+
fn seek_mid_multibyte_char_snaps_to_line_start() {
376+
let dir = create_test_dir("mid_utf8");
377+
// "café\n" = 6 bytes (c=1, a=1, f=1, é=2, \n=1). Byte 4 lands inside 'é'.
378+
let file = write_test_file(&dir, "test.txt", "café\nplain\n");
379+
380+
let backend = ByteSeekBackend::open(&file).unwrap();
381+
let chunk = backend.get_lines(&SeekTarget::ByteOffset(4), 2).unwrap();
382+
383+
// Should backward-scan to byte 0 (start of "café") since byte 4 is mid-char inside first line
384+
assert_eq!(chunk.byte_offset, 0);
385+
assert_eq!(chunk.lines[0], "café");
386+
387+
cleanup(&dir);
388+
}
389+
390+
#[test]
391+
fn seek_mid_emoji_snaps_to_line_start() {
392+
let dir = create_test_dir("mid_emoji");
393+
// "🦀go\n" = 7 bytes (🦀=4, g=1, o=1, \n=1). Byte 2 lands inside the emoji.
394+
let file = write_test_file(&dir, "test.txt", "🦀go\nnext\n");
395+
396+
let backend = ByteSeekBackend::open(&file).unwrap();
397+
let chunk = backend.get_lines(&SeekTarget::ByteOffset(2), 2).unwrap();
398+
399+
assert_eq!(chunk.byte_offset, 0);
400+
assert_eq!(chunk.lines[0], "🦀go");
401+
402+
cleanup(&dir);
403+
}
404+
405+
#[test]
406+
fn seek_mid_cjk_char_snaps_to_line_start() {
407+
let dir = create_test_dir("mid_cjk");
408+
// '漢' = 3 bytes in UTF-8. "漢字\n" = 7 bytes. Byte 1 lands mid-character.
409+
let file = write_test_file(&dir, "test.txt", "漢字\nnext\n");
410+
411+
let backend = ByteSeekBackend::open(&file).unwrap();
412+
let chunk = backend.get_lines(&SeekTarget::ByteOffset(1), 2).unwrap();
413+
414+
assert_eq!(chunk.byte_offset, 0);
415+
assert_eq!(chunk.lines[0], "漢字");
416+
417+
cleanup(&dir);
418+
}
419+
420+
#[test]
421+
fn read_lines_with_mixed_scripts() {
422+
let dir = create_test_dir("mixed_scripts");
423+
let content = "hello café\n漢字テスト\n🎉🦀🌍\nplain\n";
424+
let file = write_test_file(&dir, "test.txt", content);
425+
426+
let backend = ByteSeekBackend::open(&file).unwrap();
427+
let chunk = backend.get_lines(&SeekTarget::ByteOffset(0), 10).unwrap();
428+
429+
assert_eq!(chunk.lines.len(), 4);
430+
assert_eq!(chunk.lines[0], "hello café");
431+
assert_eq!(chunk.lines[1], "漢字テスト");
432+
assert_eq!(chunk.lines[2], "🎉🦀🌍");
433+
assert_eq!(chunk.lines[3], "plain");
434+
435+
cleanup(&dir);
436+
}
437+
438+
#[test]
439+
fn search_emoji_utf16_column() {
440+
let dir = create_test_dir("search_emoji");
441+
// '🦀' = 4 bytes UTF-8, 2 UTF-16 code units
442+
let file = write_test_file(&dir, "test.txt", "🦀rust is great\n");
443+
444+
let backend = ByteSeekBackend::open(&file).unwrap();
445+
let cancel = AtomicBool::new(false);
446+
let results: Mutex<Vec<SearchMatch>> = Mutex::new(Vec::new());
447+
let progress: Mutex<u64> = Mutex::new(0);
448+
449+
backend.search("rust", &cancel, &results, &progress).unwrap();
450+
let matches = results.lock().unwrap();
451+
452+
assert_eq!(matches.len(), 1);
453+
assert_eq!(matches[0].column, 2); // 🦀 = 2 UTF-16 code units
454+
assert_eq!(matches[0].length, 4); // "rust" = 4 ASCII chars = 4 UTF-16 units
455+
456+
cleanup(&dir);
457+
}
458+
459+
#[test]
460+
fn search_cjk_utf16_column() {
461+
let dir = create_test_dir("search_cjk");
462+
// CJK chars are 3 bytes UTF-8 but 1 UTF-16 code unit each
463+
let file = write_test_file(&dir, "test.txt", "漢字test\n");
464+
465+
let backend = ByteSeekBackend::open(&file).unwrap();
466+
let cancel = AtomicBool::new(false);
467+
let results: Mutex<Vec<SearchMatch>> = Mutex::new(Vec::new());
468+
let progress: Mutex<u64> = Mutex::new(0);
469+
470+
backend.search("test", &cancel, &results, &progress).unwrap();
471+
let matches = results.lock().unwrap();
472+
473+
assert_eq!(matches.len(), 1);
474+
assert_eq!(matches[0].column, 2); // 2 CJK chars = 2 UTF-16 code units
475+
assert_eq!(matches[0].length, 4);
476+
477+
cleanup(&dir);
478+
}
479+
480+
#[test]
481+
fn read_emoji_only_lines() {
482+
let dir = create_test_dir("emoji_only");
483+
let file = write_test_file(&dir, "test.txt", "🎉🎊🎈\n🦀🦞🦐\n");
484+
485+
let backend = ByteSeekBackend::open(&file).unwrap();
486+
let chunk = backend.get_lines(&SeekTarget::ByteOffset(0), 10).unwrap();
487+
488+
assert_eq!(chunk.lines[0], "🎉🎊🎈");
489+
assert_eq!(chunk.lines[1], "🦀🦞🦐");
490+
491+
cleanup(&dir);
492+
}
493+
494+
#[test]
495+
fn read_file_starting_with_bom() {
496+
let dir = create_test_dir("bom");
497+
// UTF-8 BOM is EF BB BF (3 bytes), rendered as U+FEFF
498+
let mut content = vec![0xEF, 0xBB, 0xBF];
499+
content.extend_from_slice("hello\nworld\n".as_bytes());
500+
let file = dir.join("bom.txt");
501+
fs::write(&file, &content).unwrap();
502+
503+
let backend = ByteSeekBackend::open(&file).unwrap();
504+
let chunk = backend.get_lines(&SeekTarget::ByteOffset(0), 10).unwrap();
505+
506+
// BOM should appear as U+FEFF at start of first line
507+
assert!(chunk.lines[0].starts_with('\u{FEFF}'));
508+
assert!(chunk.lines[0].ends_with("hello"));
509+
assert_eq!(chunk.lines[1], "world");
510+
511+
cleanup(&dir);
512+
}

0 commit comments

Comments
 (0)