Skip to content

Commit 1f3fc5f

Browse files
committed
Merge fix-f-drive-parity: G-drive CRLF footer + F-drive hardlink parity fixes
2 parents b6d972e + 26ae7a5 commit 1f3fc5f

File tree

9 files changed

+274
-46
lines changed

9 files changed

+274
-46
lines changed

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

Lines changed: 173 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
//! Output helpers for CLI search commands.
2+
//!
3+
//! Exception: This file exceeds 800 lines due to comprehensive test suite
4+
//! covering DataFrame/native output parity and footer formatting variations.
25
36
extern crate alloc;
47

58
use alloc::borrow::Cow;
69
use core::fmt::Write as _;
10+
use core::time::Duration;
711
use std::fs::File;
812
use std::io::{BufWriter, Write};
913
#[cfg(windows)]
@@ -16,6 +20,16 @@ use tracing::info;
1620
use uffs_core::output::{CPP_COLUMN_ORDER, OutputColumn, OutputConfig};
1721
use uffs_core::{export_json, export_table};
1822

23+
/// Context for C++ baseline-compatible footer formatting.
24+
pub(super) struct CppFooterContext<'a> {
25+
/// Drive letters to include in the footer (e.g., `['C', 'D']`).
26+
pub(super) output_targets: &'a [char],
27+
/// Elapsed time since search started.
28+
pub(super) elapsed: Duration,
29+
/// Original search pattern string.
30+
pub(super) pattern: &'a str,
31+
}
32+
1933
/// Streaming output writer for multi-drive search.
2034
///
2135
/// Supports CSV (header + rows) and NDJSON (one JSON object per line) formats.
@@ -257,7 +271,7 @@ pub(super) fn write_native_results(
257271
format: &str,
258272
out: &str,
259273
output_config: &OutputConfig,
260-
output_targets: &[char],
274+
footer_ctx: &CppFooterContext<'_>,
261275
) -> Result<()> {
262276
let normalized_format = format.to_ascii_lowercase();
263277
let is_console = matches!(
@@ -274,7 +288,7 @@ pub(super) fn write_native_results(
274288
&normalized_format,
275289
&mut stdout,
276290
output_config,
277-
output_targets,
291+
footer_ctx,
278292
)?;
279293
stdout.flush()?;
280294
} else {
@@ -287,7 +301,7 @@ pub(super) fn write_native_results(
287301
&normalized_format,
288302
&mut writer,
289303
output_config,
290-
output_targets,
304+
footer_ctx,
291305
)?;
292306
writer.flush()?;
293307

@@ -304,7 +318,7 @@ fn write_native_results_to<W: Write>(
304318
format: &str,
305319
writer: &mut W,
306320
output_config: &OutputConfig,
307-
output_targets: &[char],
321+
footer_ctx: &CppFooterContext<'_>,
308322
) -> Result<()> {
309323
let output_cols = selected_output_columns(output_config);
310324
let fixed_tz = chrono::FixedOffset::east_opt(output_config.timezone_offset_secs);
@@ -338,7 +352,7 @@ fn write_native_results_to<W: Write>(
338352
}
339353

340354
if format == "custom" {
341-
write_cpp_drive_footer(writer, output_targets)?;
355+
write_cpp_drive_footer(writer, footer_ctx)?;
342356
}
343357

344358
Ok(())
@@ -882,12 +896,20 @@ pub(super) fn write_results(
882896
out: &str,
883897
output_config: &OutputConfig,
884898
output_targets: &[char],
899+
elapsed: Duration,
900+
pattern: &str,
885901
) -> Result<()> {
886902
let is_console = matches!(
887903
out.to_lowercase().as_str(),
888904
"console" | "con" | "term" | "terminal"
889905
);
890906

907+
let footer_ctx = CppFooterContext {
908+
output_targets,
909+
elapsed,
910+
pattern,
911+
};
912+
891913
if is_console {
892914
let stdout_handle = std::io::stdout();
893915
let mut stdout = stdout_handle.lock();
@@ -896,7 +918,7 @@ pub(super) fn write_results(
896918
"csv" => output_config.write(results, &mut stdout)?,
897919
"custom" => {
898920
output_config.write(results, &mut stdout)?;
899-
write_cpp_drive_footer(&mut stdout, output_targets)?;
921+
write_cpp_drive_footer(&mut stdout, &footer_ctx)?;
900922
}
901923
_ => export_table(results, &mut stdout)?,
902924
}
@@ -910,7 +932,7 @@ pub(super) fn write_results(
910932
"json" => export_json(results, &mut writer)?,
911933
"custom" => {
912934
output_config.write(results, &mut writer)?;
913-
write_cpp_drive_footer(&mut writer, output_targets)?;
935+
write_cpp_drive_footer(&mut writer, &footer_ctx)?;
914936
}
915937
_ => output_config.write(results, &mut writer)?,
916938
}
@@ -923,20 +945,33 @@ pub(super) fn write_results(
923945
}
924946

925947
/// Append the legacy C++ drive footer for baseline-compatible custom output.
926-
fn write_cpp_drive_footer<W: Write>(writer: &mut W, output_targets: &[char]) -> Result<()> {
927-
if output_targets.is_empty() {
948+
///
949+
/// Uses CRLF line endings (`\r\n`) to match C++ baseline behavior.
950+
/// When `elapsed` is <= 1 second, appends the fast-scan message.
951+
fn write_cpp_drive_footer<W: Write>(writer: &mut W, ctx: &CppFooterContext<'_>) -> Result<()> {
952+
if ctx.output_targets.is_empty() {
928953
return Ok(());
929954
}
930955

931-
writeln!(writer)?;
932-
writeln!(writer)?;
933-
writeln!(
956+
write!(writer, "\r\n")?;
957+
write!(writer, "\r\n")?;
958+
write!(
934959
writer,
935-
"Drives? \t{}\t{}",
936-
output_targets.len(),
937-
format_cpp_drive_letters(output_targets)
960+
"Drives? \t{}\t{}\r\n",
961+
ctx.output_targets.len(),
962+
format_cpp_drive_letters(ctx.output_targets)
938963
)?;
939-
writeln!(writer)?;
964+
write!(writer, "\r\n")?;
965+
966+
if ctx.elapsed.as_secs() <= 1 {
967+
write!(
968+
writer,
969+
"MMMmmm that was FAST ... maybe your searchstring was wrong?\t{pattern}\r\n",
970+
pattern = ctx.pattern
971+
)?;
972+
write!(writer, "Search path. E.g. 'C:/' or 'C:\\Prog**' \r\n")?;
973+
}
974+
940975
Ok(())
941976
}
942977

@@ -1065,13 +1100,20 @@ mod tests {
10651100
format: &str,
10661101
output_config: &OutputConfig,
10671102
output_targets: &[char],
1103+
elapsed: Duration,
1104+
pattern: &str,
10681105
) -> Result<String> {
10691106
let mut output = Vec::new();
1107+
let footer_ctx = CppFooterContext {
1108+
output_targets,
1109+
elapsed,
1110+
pattern,
1111+
};
10701112
match format {
10711113
"json" => export_json(results, &mut output)?,
10721114
"custom" => {
10731115
output_config.write(results, &mut output)?;
1074-
write_cpp_drive_footer(&mut output, output_targets)?;
1116+
write_cpp_drive_footer(&mut output, &footer_ctx)?;
10751117
}
10761118
_ => output_config.write(results, &mut output)?,
10771119
}
@@ -1084,15 +1126,22 @@ mod tests {
10841126
format: &str,
10851127
output_config: &OutputConfig,
10861128
output_targets: &[char],
1129+
elapsed: Duration,
1130+
pattern: &str,
10871131
) -> Result<String> {
10881132
let mut output = Vec::new();
1133+
let footer_ctx = CppFooterContext {
1134+
output_targets,
1135+
elapsed,
1136+
pattern,
1137+
};
10891138
write_native_results_to(
10901139
index,
10911140
results,
10921141
format,
10931142
&mut output,
10941143
output_config,
1095-
output_targets,
1144+
&footer_ctx,
10961145
)?;
10971146
String::from_utf8(output).map_err(Into::into)
10981147
}
@@ -1123,6 +1172,8 @@ mod tests {
11231172
&path.to_string_lossy(),
11241173
&output_config,
11251174
&['C', 'D'],
1175+
Duration::from_secs(2),
1176+
"*.txt",
11261177
)?;
11271178

11281179
let written = fs::read_to_string(&path)?;
@@ -1146,6 +1197,8 @@ mod tests {
11461197
&path.to_string_lossy(),
11471198
&output_config,
11481199
&['C', 'D'],
1200+
Duration::from_secs(2),
1201+
"*.txt",
11491202
)?;
11501203

11511204
let written = fs::read_to_string(&path)?;
@@ -1155,10 +1208,10 @@ mod tests {
11551208
written,
11561209
concat!(
11571210
"\"C:\\Temp\\file.txt\",\"file.txt\"\n",
1158-
"\n",
1159-
"\n",
1160-
"Drives? \t2\tC:|D:\n",
1161-
"\n"
1211+
"\r\n",
1212+
"\r\n",
1213+
"Drives? \t2\tC:|D:\r\n",
1214+
"\r\n"
11621215
)
11631216
);
11641217
Ok(())
@@ -1176,6 +1229,8 @@ mod tests {
11761229
&path.to_string_lossy(),
11771230
&output_config,
11781231
&['C', 'D'],
1232+
Duration::from_secs(2),
1233+
"*.txt",
11791234
)?;
11801235

11811236
let written = fs::read_to_string(&path)?;
@@ -1239,15 +1294,26 @@ mod tests {
12391294
let output_config = OutputConfig::new().with_columns(
12401295
"path,name,pathonly,size,sizeondisk,created,readonly,archive,hidden,directory,descendants,treesize,treeallocated,type",
12411296
);
1297+
let elapsed = Duration::from_secs(2);
1298+
let pattern = "*.txt";
12421299

12431300
let expected = write_results_to_string(
12441301
&results_to_dataframe(&index, results.clone(), true)?,
12451302
"custom",
12461303
&output_config,
12471304
&['C'],
1305+
elapsed,
1306+
pattern,
1307+
)?;
1308+
let actual = write_native_results_to_string(
1309+
&index,
1310+
&results,
1311+
"custom",
1312+
&output_config,
1313+
&['C'],
1314+
elapsed,
1315+
pattern,
12481316
)?;
1249-
let actual =
1250-
write_native_results_to_string(&index, &results, "custom", &output_config, &['C'])?;
12511317

12521318
assert_eq!(actual, expected);
12531319
Ok(())
@@ -1262,17 +1328,98 @@ mod tests {
12621328
.with_separator(";")
12631329
.with_quote("'")
12641330
.with_header(false);
1331+
let elapsed = Duration::from_secs(2);
1332+
let pattern = "*.txt";
12651333

12661334
let expected = write_results_to_string(
12671335
&results_to_dataframe(&index, results.clone(), true)?,
12681336
"csv",
12691337
&output_config,
12701338
&['C'],
1339+
elapsed,
1340+
pattern,
1341+
)?;
1342+
let actual = write_native_results_to_string(
1343+
&index,
1344+
&results,
1345+
"csv",
1346+
&output_config,
1347+
&['C'],
1348+
elapsed,
1349+
pattern,
12711350
)?;
1272-
let actual =
1273-
write_native_results_to_string(&index, &results, "csv", &output_config, &['C'])?;
12741351

12751352
assert_eq!(actual, expected);
12761353
Ok(())
12771354
}
1355+
1356+
#[test]
1357+
fn test_cpp_footer_includes_fast_scan_message_when_elapsed_le_1s() -> TestResult {
1358+
let path = temp_output_path("txt");
1359+
let results = sample_df()?;
1360+
let output_config = OutputConfig::new()
1361+
.with_columns("path,name")
1362+
.with_header(false);
1363+
1364+
write_results(
1365+
&results,
1366+
"custom",
1367+
&path.to_string_lossy(),
1368+
&output_config,
1369+
&['G'],
1370+
Duration::from_millis(999),
1371+
">G:.*",
1372+
)?;
1373+
1374+
let written = fs::read_to_string(&path)?;
1375+
drop(fs::remove_file(&path));
1376+
1377+
assert_eq!(
1378+
written,
1379+
concat!(
1380+
"\"C:\\Temp\\file.txt\",\"file.txt\"\n",
1381+
"\r\n",
1382+
"\r\n",
1383+
"Drives? \t1\tG:\r\n",
1384+
"\r\n",
1385+
"MMMmmm that was FAST ... maybe your searchstring was wrong?\t>G:.*\r\n",
1386+
"Search path. E.g. 'C:/' or 'C:\\Prog**' \r\n"
1387+
)
1388+
);
1389+
Ok(())
1390+
}
1391+
1392+
#[test]
1393+
fn test_cpp_footer_omits_fast_scan_message_when_elapsed_gt_1s() -> TestResult {
1394+
let path = temp_output_path("txt");
1395+
let results = sample_df()?;
1396+
let output_config = OutputConfig::new()
1397+
.with_columns("path,name")
1398+
.with_header(false);
1399+
1400+
write_results(
1401+
&results,
1402+
"custom",
1403+
&path.to_string_lossy(),
1404+
&output_config,
1405+
&['G'],
1406+
Duration::from_secs(2),
1407+
">G:.*",
1408+
)?;
1409+
1410+
let written = fs::read_to_string(&path)?;
1411+
drop(fs::remove_file(&path));
1412+
1413+
assert_eq!(
1414+
written,
1415+
concat!(
1416+
"\"C:\\Temp\\file.txt\",\"file.txt\"\n",
1417+
"\r\n",
1418+
"\r\n",
1419+
"Drives? \t1\tG:\r\n",
1420+
"\r\n"
1421+
)
1422+
);
1423+
Ok(())
1424+
}
12781425
}

crates/uffs-cli/src/commands/raw_io.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
//! Raw MFT and data-loading helpers for CLI search commands.
2+
//!
3+
//! Exception: This file consolidates MFT reading, query filtering, and
4+
//! multi-drive orchestration logic which are tightly coupled in the CLI command
5+
//! pipeline.
26
37
#![expect(
48
clippy::single_call_fn,

0 commit comments

Comments
 (0)