Skip to content

Commit 6fbc1b9

Browse files
committed
refactor: move save-raw and load-raw commands to uffs_mft
- SaveRaw and LoadRaw are MFT-specific operations, not search features - uffs CLI now focuses on file search: search, index, info, stats - uffs_mft handles MFT operations: read, info, drives, bench, save-raw, load-raw - Cleaner separation of concerns between binaries
1 parent c2c3d8c commit 6fbc1b9

File tree

3 files changed

+200
-249
lines changed

3 files changed

+200
-249
lines changed

crates/uffs-cli/src/commands.rs

Lines changed: 22 additions & 200 deletions
Original file line numberDiff line numberDiff line change
@@ -52,31 +52,11 @@ fn add_drive_progress(multi_progress: &MultiProgress, drive: char) -> ProgressBa
5252
progress_bar
5353
}
5454

55-
/// Create a progress bar for saving raw MFT bytes.
56-
/// Returns `None` if progress is disabled via `UFFS_NO_PROGRESS=1`.
57-
#[cfg(windows)]
58-
fn create_save_raw_progress() -> Option<ProgressBar> {
59-
if is_progress_disabled() {
60-
return None;
61-
}
62-
63-
let progress_bar = ProgressBar::new(0);
64-
progress_bar.set_style(
65-
ProgressStyle::default_bar()
66-
.template("{spinner:.cyan} [{elapsed_precise}] {bar:40.cyan/blue} {bytes}/{total_bytes} 💾 saving...")
67-
.unwrap_or_else(|_| ProgressStyle::default_bar())
68-
.progress_chars("━━╸"),
69-
);
70-
Some(progress_bar)
71-
}
72-
7355
use uffs_core::output::OutputConfig;
7456
use uffs_core::pattern::ParsedPattern;
7557
use uffs_core::tree::add_tree_columns;
7658
use uffs_core::{MftQuery, export_csv, export_json, export_table};
77-
#[cfg(windows)]
78-
use uffs_mft::SaveRawOptions;
79-
use uffs_mft::{MftProgress, MftReader, load_raw_mft_header};
59+
use uffs_mft::{MftProgress, MftReader};
8060

8161
/// Search for files matching a pattern.
8262
///
@@ -633,6 +613,27 @@ pub fn info(path: &Path) -> Result<()> {
633613
Ok(())
634614
}
635615

616+
/// Format file size in human-readable format.
617+
#[allow(clippy::cast_precision_loss, clippy::float_arithmetic)]
618+
fn format_size(bytes: u64) -> String {
619+
const KB: u64 = 1024;
620+
const MB: u64 = KB * 1024;
621+
const GB: u64 = MB * 1024;
622+
const TB: u64 = GB * 1024;
623+
624+
if bytes >= TB {
625+
format!("{:.2} TB", bytes as f64 / TB as f64)
626+
} else if bytes >= GB {
627+
format!("{:.2} GB", bytes as f64 / GB as f64)
628+
} else if bytes >= MB {
629+
format!("{:.2} MB", bytes as f64 / MB as f64)
630+
} else if bytes >= KB {
631+
format!("{:.2} KB", bytes as f64 / KB as f64)
632+
} else {
633+
format!("{bytes} B")
634+
}
635+
}
636+
636637
/// Show statistics about files in an index.
637638
///
638639
/// # Errors
@@ -690,182 +691,3 @@ pub fn stats(path: &Path, top: u32) -> Result<()> {
690691

691692
Ok(())
692693
}
693-
694-
/// Format file size in human-readable format.
695-
#[allow(clippy::cast_precision_loss, clippy::float_arithmetic)]
696-
fn format_size(bytes: u64) -> String {
697-
const KB: u64 = 1024;
698-
const MB: u64 = KB * 1024;
699-
const GB: u64 = MB * 1024;
700-
const TB: u64 = GB * 1024;
701-
702-
if bytes >= TB {
703-
format!("{:.2} TB", bytes as f64 / TB as f64)
704-
} else if bytes >= GB {
705-
format!("{:.2} GB", bytes as f64 / GB as f64)
706-
} else if bytes >= MB {
707-
format!("{:.2} MB", bytes as f64 / MB as f64)
708-
} else if bytes >= KB {
709-
format!("{:.2} KB", bytes as f64 / KB as f64)
710-
} else {
711-
format!("{bytes} B")
712-
}
713-
}
714-
715-
/// Save raw MFT bytes to a file for offline analysis.
716-
#[cfg(windows)]
717-
pub async fn save_raw(
718-
drive: char,
719-
output: &Path,
720-
compress: bool,
721-
compression_level: i32,
722-
) -> Result<()> {
723-
info!(drive = %drive, "Reading raw MFT from drive");
724-
725-
let reader = MftReader::open(drive)
726-
.await
727-
.with_context(|| format!("Failed to open drive {drive}:"))?;
728-
729-
// Create progress bar (None if disabled)
730-
let pb = create_save_raw_progress();
731-
732-
let options = SaveRawOptions {
733-
compress,
734-
compression_level,
735-
};
736-
737-
let header = reader
738-
.save_raw_to_file(output, &options)
739-
.await
740-
.with_context(|| format!("Failed to save raw MFT to {}", output.display()))?;
741-
742-
if let Some(ref p) = pb {
743-
p.finish_and_clear();
744-
}
745-
746-
let mut stdout = std::io::stdout().lock();
747-
writeln!(stdout)?;
748-
writeln!(stdout, "=== Raw MFT Saved ===")?;
749-
writeln!(stdout, "Output: {}", output.display())?;
750-
writeln!(stdout, "Records: {}", header.record_count)?;
751-
writeln!(stdout, "Record size: {} bytes", header.record_size)?;
752-
writeln!(
753-
stdout,
754-
"Original size: {}",
755-
format_size(header.original_size)
756-
)?;
757-
if header.is_compressed() {
758-
writeln!(
759-
stdout,
760-
"Compressed size: {}",
761-
format_size(header.compressed_size)
762-
)?;
763-
#[allow(clippy::cast_precision_loss, clippy::float_arithmetic)]
764-
let ratio = header.compressed_size as f64 / header.original_size as f64 * 100.0_f64;
765-
writeln!(stdout, "Compression: {ratio:.1}%")?;
766-
} else {
767-
writeln!(stdout, "Compression: none")?;
768-
}
769-
770-
Ok(())
771-
}
772-
773-
/// Save raw MFT bytes - non-Windows stub.
774-
#[cfg(not(windows))]
775-
// Platform-specific stub must match Windows signature; called once per platform is expected.
776-
#[allow(clippy::unused_async, clippy::single_call_fn)]
777-
pub async fn save_raw(
778-
_drive: char,
779-
_output: &Path,
780-
_compress: bool,
781-
_compression_level: i32,
782-
) -> Result<()> {
783-
anyhow::bail!("Raw MFT saving is only supported on Windows");
784-
}
785-
786-
/// Load raw MFT from a saved file.
787-
///
788-
/// # Errors
789-
///
790-
/// Returns an error if:
791-
/// - The raw MFT file cannot be loaded
792-
/// - Writing to stdout fails
793-
/// - On non-Windows: always fails (NTFS parsing not supported)
794-
// CLI command handler - separate function for testability and maintainability.
795-
#[allow(clippy::single_call_fn)]
796-
pub fn load_raw(input: &Path, output: Option<&Path>, info_only: bool) -> Result<()> {
797-
// Load header first
798-
let header = load_raw_mft_header(input)
799-
.with_context(|| format!("Failed to load raw MFT header from {}", input.display()))?;
800-
801-
let mut stdout = std::io::stdout().lock();
802-
writeln!(stdout, "=== Raw MFT File Info ===")?;
803-
writeln!(stdout, "File: {}", input.display())?;
804-
writeln!(stdout, "Version: {}", header.version)?;
805-
writeln!(stdout, "Records: {}", header.record_count)?;
806-
writeln!(stdout, "Record size: {} bytes", header.record_size)?;
807-
writeln!(
808-
stdout,
809-
"Original size: {}",
810-
format_size(header.original_size)
811-
)?;
812-
if header.is_compressed() {
813-
writeln!(
814-
stdout,
815-
"Compressed size: {}",
816-
format_size(header.compressed_size)
817-
)?;
818-
#[allow(clippy::cast_precision_loss, clippy::float_arithmetic)]
819-
let ratio = header.compressed_size as f64 / header.original_size as f64 * 100.0_f64;
820-
writeln!(stdout, "Compression: {ratio:.1}%")?;
821-
} else {
822-
writeln!(stdout, "Compression: none")?;
823-
}
824-
// Drop stdout lock before potentially long operations
825-
drop(stdout);
826-
827-
if info_only {
828-
return Ok(());
829-
}
830-
831-
// Parse and export
832-
#[cfg(windows)]
833-
{
834-
let output = output.context("--output is required when not using --info-only")?;
835-
836-
info!("Parsing MFT records");
837-
838-
let df = MftReader::load_raw_to_dataframe(input)
839-
.with_context(|| format!("Failed to parse raw MFT from {}", input.display()))?;
840-
841-
info!(records = df.height(), "Parsed records");
842-
843-
// Determine output format from extension
844-
let ext = output
845-
.extension()
846-
.and_then(|e| e.to_str())
847-
.unwrap_or("parquet");
848-
849-
match ext {
850-
"csv" => {
851-
let mut file = File::create(output)?;
852-
export_csv(&df, &mut file)?;
853-
info!(path = %output.display(), "Exported to CSV");
854-
}
855-
_ => {
856-
let mut df = df;
857-
MftReader::save_parquet(&mut df, output)?;
858-
info!(path = %output.display(), "Exported to Parquet");
859-
}
860-
}
861-
862-
Ok(())
863-
}
864-
865-
#[cfg(not(windows))]
866-
{
867-
// Silence unused variable warning on non-Windows
868-
let _: Option<&Path> = output;
869-
anyhow::bail!("Raw MFT parsing is only supported on Windows (requires NTFS parsing)");
870-
}
871-
}

crates/uffs-cli/src/main.rs

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -316,39 +316,6 @@ enum Commands {
316316
#[arg(long, default_value = "10")]
317317
top: u32,
318318
},
319-
320-
/// Save raw MFT bytes to a file for offline analysis
321-
SaveRaw {
322-
/// Drive letter to read MFT from (e.g., C or C:)
323-
#[arg(short, long, value_parser = parse_drive_letter)]
324-
drive: char,
325-
326-
/// Output file path for raw MFT data
327-
#[arg(short, long)]
328-
output: PathBuf,
329-
330-
/// Compress the output using zstd
331-
#[arg(short, long, default_value = "true")]
332-
compress: bool,
333-
334-
/// Compression level (1-22, default 3)
335-
#[arg(long, default_value = "3")]
336-
compression_level: i32,
337-
},
338-
339-
/// Load raw MFT from a saved file and export to parquet/csv
340-
LoadRaw {
341-
/// Input raw MFT file path
342-
input: PathBuf,
343-
344-
/// Output file path (parquet or csv based on extension)
345-
#[arg(short, long)]
346-
output: Option<PathBuf>,
347-
348-
/// Show info about the raw MFT file only (don't parse)
349-
#[arg(long)]
350-
info_only: bool,
351-
},
352319
}
353320

354321
/// Initialize logging with terminal + file support.
@@ -515,21 +482,6 @@ async fn run() -> Result<()> {
515482
Some(Commands::Stats { index, top }) => {
516483
commands::stats(&index, top)?;
517484
}
518-
Some(Commands::SaveRaw {
519-
drive,
520-
output,
521-
compress,
522-
compression_level,
523-
}) => {
524-
commands::save_raw(drive, &output, compress, compression_level).await?;
525-
}
526-
Some(Commands::LoadRaw {
527-
input,
528-
output,
529-
info_only,
530-
}) => {
531-
commands::load_raw(&input, output.as_deref(), info_only)?;
532-
}
533485
None => {
534486
// Default action: search with top-level arguments
535487
if let Some(pattern) = cli.pattern {

0 commit comments

Comments
 (0)