Skip to content

Commit 0e08ff2

Browse files
committed
chore: development v0.4.2 - comprehensive testing complete [auto-commit]
1 parent 9033e92 commit 0e08ff2

File tree

15 files changed

+1196
-3108
lines changed

15 files changed

+1196
-3108
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ exclude = [
3636
# Workspace Package Metadata (inherited by all crates)
3737
# ─────────────────────────────────────────────────────────────────────────────
3838
[workspace.package]
39-
version = "0.4.1"
39+
version = "0.4.2"
4040
edition = "2024"
4141
rust-version = "1.85"
4242
license = "MPL-2.0 OR LicenseRef-UFFS-Commercial"

LOG/Output

Lines changed: 827 additions & 3068 deletions
Large diffs are not rendered by default.

crates/uffs-cli/src/commands/output/mod.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,11 @@ fn write_cpp_drive_footer<W: Write + ?Sized>(
434434
)?;
435435
write!(writer, "\r\n")?;
436436

437-
if ctx.row_count < 20_000 {
437+
// Only show the "too few results" warning for full-scan patterns (* or empty).
438+
// Filtered/regex/glob queries naturally return few results — that's not an
439+
// error.
440+
let is_full_scan = matches!(ctx.pattern, "" | "*" | "**" | "**/*");
441+
if ctx.row_count < 20_000 && is_full_scan {
438442
write!(
439443
writer,
440444
"MMMmmm that was FAST ... maybe your searchstring was wrong?\t{pattern}\r\n",

crates/uffs-cli/src/commands/output/output_tests.rs

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,8 @@ fn test_write_results_custom_file_appends_cpp_drive_footer() -> Result<()> {
171171
let written = fs::read_to_string(&path)?;
172172
drop(fs::remove_file(&path));
173173

174-
// With 1 row (< 20,000), should include fast-scan message
174+
// With glob pattern "*.txt", few results is expected — no MMMmmm warning.
175+
// The warning only triggers for full-scan patterns (*, **, **/*).
175176
assert_eq!(
176177
written,
177178
concat!(
@@ -180,8 +181,6 @@ fn test_write_results_custom_file_appends_cpp_drive_footer() -> Result<()> {
180181
"\r\n",
181182
"Drives? \t2\tC:|D:\r\n",
182183
"\r\n",
183-
"MMMmmm that was FAST ... maybe your searchstring was wrong?\t*.txt\r\n",
184-
"Search path. E.g. 'C:/' or 'C:\\Prog**' \r\n"
185184
)
186185
);
187186
Ok(())
@@ -308,21 +307,22 @@ fn test_streaming_output_custom_format_includes_footer() -> TestResult {
308307
}
309308

310309
#[test]
311-
fn test_cpp_footer_includes_fast_scan_message_when_elapsed_le_1s() -> TestResult {
310+
fn test_cpp_footer_includes_fast_scan_message_for_full_scan_pattern() -> TestResult {
312311
let path = temp_output_path("txt");
313312
let results = sample_df()?;
314313
let output_config = OutputConfig::new()
315314
.with_columns("path,name")
316315
.with_header(false);
317316

317+
// Full-scan pattern "*" with few results → should trigger the warning
318318
write_results(
319319
&results,
320320
"custom",
321321
&path.to_string_lossy(),
322322
&output_config,
323323
&['G'],
324324
Duration::from_millis(999),
325-
">G:.*",
325+
"*",
326326
)?;
327327

328328
let written = fs::read_to_string(&path)?;
@@ -336,13 +336,49 @@ fn test_cpp_footer_includes_fast_scan_message_when_elapsed_le_1s() -> TestResult
336336
"\r\n",
337337
"Drives? \t1\tG:\r\n",
338338
"\r\n",
339-
"MMMmmm that was FAST ... maybe your searchstring was wrong?\t>G:.*\r\n",
339+
"MMMmmm that was FAST ... maybe your searchstring was wrong?\t*\r\n",
340340
"Search path. E.g. 'C:/' or 'C:\\Prog**' \r\n"
341341
)
342342
);
343343
Ok(())
344344
}
345345

346+
#[test]
347+
fn test_cpp_footer_omits_fast_scan_message_for_regex_pattern() -> TestResult {
348+
let path = temp_output_path("txt");
349+
let results = sample_df()?;
350+
let output_config = OutputConfig::new()
351+
.with_columns("path,name")
352+
.with_header(false);
353+
354+
// Regex pattern with few results → should NOT trigger the warning
355+
// (few results is expected for filtered queries)
356+
write_results(
357+
&results,
358+
"custom",
359+
&path.to_string_lossy(),
360+
&output_config,
361+
&['G'],
362+
Duration::from_millis(999),
363+
">G:.*",
364+
)?;
365+
366+
let written = fs::read_to_string(&path)?;
367+
drop(fs::remove_file(&path));
368+
369+
assert_eq!(
370+
written,
371+
concat!(
372+
"\"C:\\Temp\\file.txt\",\"file.txt\"\n",
373+
"\r\n",
374+
"\r\n",
375+
"Drives? \t1\tG:\r\n",
376+
"\r\n",
377+
)
378+
);
379+
Ok(())
380+
}
381+
346382
#[test]
347383
fn test_cpp_footer_omits_fast_scan_message_when_elapsed_gt_1s() -> TestResult {
348384
let path = temp_output_path("txt");

crates/uffs-cli/src/commands/search/live.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,15 @@ struct IndexStreamConfig<'a> {
7373
/// Stream results from a preloaded MFT index.
7474
#[cfg(windows)]
7575
fn run_index_streaming(config: &IndexStreamConfig<'_>) -> Result<usize> {
76-
let cpp_pattern = format!(
77-
">{}:{}",
78-
config.index.volume,
79-
config.pattern.replace('*', ".*")
80-
);
76+
let cpp_pattern = if config.pattern.starts_with('>') {
77+
config.pattern.to_owned()
78+
} else {
79+
format!(
80+
">{}:{}",
81+
config.index.volume,
82+
config.pattern.replace('*', ".*")
83+
)
84+
};
8185

8286
if config.is_full_scan {
8387
info!(

crates/uffs-cli/src/commands/search/single_file.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,16 @@ pub(super) fn run_single_file_streaming(config: &SingleFileStreamConfig<'_>) ->
8888
)?;
8989

9090
let t_output = std::time::Instant::now();
91-
let cpp_pattern = format!(
92-
">{}:{}",
93-
native_index.index.volume,
94-
config.pattern.replace('*', ".*")
95-
);
91+
let cpp_pattern = if config.pattern.starts_with('>') {
92+
// Already a regex — pass through unchanged for footer display
93+
config.pattern.to_owned()
94+
} else {
95+
format!(
96+
">{}:{}",
97+
native_index.index.volume,
98+
config.pattern.replace('*', ".*")
99+
)
100+
};
96101

97102
let row_count = if config.is_full_scan {
98103
info!(

crates/uffs-cli/src/commands/search/streaming_io.rs

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use uffs_core::output::OutputConfig;
66

77
use super::super::output;
88
use super::super::raw_io::QueryFilters;
9-
use super::util::extract_trailing_extension;
9+
use super::util::{extract_extensions_from_regex, extract_trailing_extension};
1010

1111
/// Shared helper: write streaming output from an `MftIndex` to file or console.
1212
///
@@ -159,17 +159,21 @@ where
159159
}
160160
}
161161

162-
/// Try to get record indices from the extension index for simple suffix
163-
/// patterns.
162+
/// Try to get record indices from the extension index for patterns with
163+
/// recognizable file extensions.
164164
///
165-
/// Returns `Some(Vec<u32>)` for patterns like `*.rs`, `*.txt` where the
166-
/// extension index provides O(matches) lookup. Returns `None` for complex
167-
/// patterns that need full-scan matching.
165+
/// Supports two modes:
166+
/// - **Glob**: `*.rs`, `*hallo*.txt` → single extension lookup
167+
/// - **Regex**: `>.*\.(jpg|png|heic)` → union of multiple extension lookups
168+
///
169+
/// Returns `Some(Vec<u32>)` with the pre-filtered record indices, or `None`
170+
/// if the pattern doesn't have extractable extensions.
168171
pub(super) fn try_get_extension_indices(
169172
index: &uffs_mft::MftIndex,
170173
filters: &QueryFilters<'_>,
171174
) -> Option<Vec<u32>> {
172-
// Only works for simple glob suffix patterns with no other filters.
175+
// Skip extension optimization when other filters are active —
176+
// the streaming writer applies these filters inline anyway.
173177
if filters.files_only
174178
|| filters.dirs_only
175179
|| filters.hide_system
@@ -181,22 +185,54 @@ pub(super) fn try_get_extension_indices(
181185
return None;
182186
}
183187

188+
let ext_index = index.extension_index.as_ref()?;
184189
let pattern = filters.parsed.pattern();
185190

186-
// Extract a trailing literal extension from ANY pattern.
187-
// Examples:
188-
// "*.txt" → ext = "txt"
189-
// "*hallo*.txt" → ext = "txt"
190-
// "foo*.rs" → ext = "rs"
191-
// "*.tar.gz" → None (multi-dot)
192-
// "*hallo*" → None (no extension)
193-
// "nice" → None (no dot)
194-
let ext = extract_trailing_extension(pattern)?;
191+
// Strategy 1: Glob patterns — single extension from trailing `.ext`
192+
if let Some(ext) = extract_trailing_extension(pattern) {
193+
let ext_lower = ext.to_ascii_lowercase();
194+
let ext_id = index.extensions.map.get(ext_lower.as_str())?;
195+
info!(
196+
ext = ext_lower,
197+
records = ext_index.get_records(*ext_id).len(),
198+
"📊 extension index hit (glob)"
199+
);
200+
return Some(ext_index.get_records(*ext_id).to_vec());
201+
}
202+
203+
// Strategy 2: Regex patterns — extract extensions from alternation group
204+
// >.*\.(jpg|png|heic) → ["jpg", "png", "heic"]
205+
// >.*\.txt → ["txt"]
206+
if let Some(extensions) = extract_extensions_from_regex(pattern) {
207+
let mut combined: Vec<u32> = Vec::new();
208+
let mut matched_exts: Vec<&str> = Vec::new();
195209

196-
let ext_index = index.extension_index.as_ref()?;
197-
let ext_lower = ext.to_ascii_lowercase();
198-
let ext_id = index.extensions.map.get(ext_lower.as_str())?;
199-
Some(ext_index.get_records(*ext_id).to_vec())
210+
for ext in &extensions {
211+
if let Some(&ext_id) = index.extensions.map.get(ext.as_str()) {
212+
let records = ext_index.get_records(ext_id);
213+
combined.extend_from_slice(records);
214+
matched_exts.push(ext);
215+
}
216+
}
217+
218+
if combined.is_empty() {
219+
return None;
220+
}
221+
222+
// Deduplicate — a record could match multiple extensions
223+
// (unlikely but possible with hardlinks having different names).
224+
combined.sort_unstable();
225+
combined.dedup();
226+
227+
info!(
228+
extensions = ?matched_exts,
229+
records = combined.len(),
230+
"📊 extension index hit (regex alternation)"
231+
);
232+
return Some(combined);
233+
}
234+
235+
None
200236
}
201237

202238
/// Build a `StreamingRecordFilter` from `QueryFilters` + extra CLI params.

0 commit comments

Comments
 (0)