Skip to content

Commit badbb78

Browse files
committed
feat: collect extract stats
1 parent e255e41 commit badbb78

7 files changed

Lines changed: 158 additions & 44 deletions

File tree

Cargo.lock

Lines changed: 22 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/wadtools/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ camino = "1.1"
2828
convert_case = "0.9.0"
2929
ureq = "2.12"
3030
rayon = "1.10"
31+
dashmap = "6"

crates/wadtools/src/commands/extract.rs

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use league_toolkit::{file::LeagueFileKind, wad::Wad};
66

77
use crate::{
88
extractor::Extractor,
9-
utils::{create_filter_pattern, WadHashtable},
9+
utils::{create_filter_pattern, format_size, WadHashtable},
1010
};
1111
use convert_case::{Case, Casing};
1212

@@ -18,6 +18,7 @@ pub struct ExtractArgs {
1818
pub hash: Option<Vec<u64>>,
1919
pub filter_invert: bool,
2020
pub overwrite: bool,
21+
pub show_stats: bool,
2122
}
2223

2324
pub fn extract(args: ExtractArgs, hashtable: &WadHashtable) -> eyre::Result<()> {
@@ -42,17 +43,61 @@ pub fn extract(args: ExtractArgs, hashtable: &WadHashtable) -> eyre::Result<()>
4243
parent.join(stem)
4344
}
4445
};
45-
let (extracted_count, skipped_existing) =
46+
let stats =
4647
extractor.extract_chunks(&output_dir, args.filter_type.as_deref(), args.overwrite)?;
4748

48-
if skipped_existing > 0 {
49-
tracing::info!(
50-
"extracted {} chunks, skipped {} existing :)",
51-
extracted_count,
52-
skipped_existing
49+
if args.show_stats {
50+
println!();
51+
println!(
52+
"{}: {}",
53+
"WAD".bright_cyan().bold(),
54+
args.input.bright_white()
55+
);
56+
println!(
57+
"{}: {} chunks ({})",
58+
"Extracted".bright_cyan().bold(),
59+
stats.extracted_count.to_string().bright_green(),
60+
format_size(stats.bytes_written).bright_white()
61+
);
62+
println!(
63+
"{}: {} existing",
64+
"Skipped".bright_cyan().bold(),
65+
stats.skipped_existing.to_string().bright_yellow()
5366
);
67+
println!(
68+
"{}: {}",
69+
"Errors".bright_cyan().bold(),
70+
if stats.error_count == 0 {
71+
stats.error_count.to_string().bright_green().to_string()
72+
} else {
73+
stats.error_count.to_string().bright_red().to_string()
74+
}
75+
);
76+
77+
if !stats.by_type.is_empty() {
78+
println!();
79+
println!("{}:", "By type".bright_cyan().bold());
80+
let mut type_entries: Vec<_> = stats.by_type.iter().collect();
81+
type_entries.sort_by(|a, b| b.1.cmp(a.1));
82+
for (kind, count) in type_entries {
83+
let name = format!("{:?}", kind).to_case(Case::Snake);
84+
println!(
85+
" {:24} {}",
86+
name.bright_magenta(),
87+
count.to_string().bright_white()
88+
);
89+
}
90+
}
5491
} else {
55-
tracing::info!("extracted {} chunks :)", extracted_count);
92+
if stats.skipped_existing > 0 {
93+
tracing::info!(
94+
"extracted {} chunks, skipped {} existing :)",
95+
stats.extracted_count,
96+
stats.skipped_existing
97+
);
98+
} else {
99+
tracing::info!("extracted {} chunks :)", stats.extracted_count);
100+
}
56101
}
57102

58103
Ok(())

crates/wadtools/src/commands/list.rs

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use std::fs::File;
66

77
use crate::{
88
extractor::{should_skip_hash, should_skip_pattern, should_skip_type},
9-
utils::{create_filter_pattern, format_chunk_path_hash, WadHashtable},
9+
utils::{create_filter_pattern, format_chunk_path_hash, format_size, WadHashtable},
1010
};
1111

1212
#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
@@ -228,19 +228,3 @@ fn print_table(output: &ListOutput, show_stats: bool) {
228228
);
229229
}
230230
}
231-
232-
fn format_size(bytes: u64) -> String {
233-
const KB: u64 = 1024;
234-
const MB: u64 = KB * 1024;
235-
const GB: u64 = MB * 1024;
236-
237-
if bytes >= GB {
238-
format!("{:.2} GB", bytes as f64 / GB as f64)
239-
} else if bytes >= MB {
240-
format!("{:.2} MB", bytes as f64 / MB as f64)
241-
} else if bytes >= KB {
242-
format!("{:.2} KB", bytes as f64 / KB as f64)
243-
} else {
244-
format!("{} B", bytes)
245-
}
246-
}

crates/wadtools/src/extractor.rs

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
use crate::utils::{is_hex_chunk_path, truncate_middle, WadHashtable};
22
use camino::{Utf8Path, Utf8PathBuf};
33
use color_eyre::eyre::{self, Ok};
4+
use dashmap::DashMap;
45
use eyre::Context;
56
use fancy_regex::Regex;
67
use league_toolkit::{
78
file::LeagueFileKind,
89
wad::{decompress_raw, Wad, WadChunk},
910
};
1011
use std::{
12+
collections::HashMap,
1113
fs::{self, File, OpenOptions},
1214
io::{self, Write},
1315
sync::{
14-
atomic::{AtomicUsize, Ordering},
16+
atomic::{AtomicU64, AtomicUsize, Ordering},
1517
mpsc,
1618
},
1719
};
@@ -20,8 +22,16 @@ use tracing_indicatif::style::ProgressStyle;
2022

2123
const MAX_LOG_PATH_LEN: usize = 120;
2224

25+
pub struct ExtractStats {
26+
pub extracted_count: usize,
27+
pub skipped_existing: usize,
28+
pub error_count: usize,
29+
pub bytes_written: u64,
30+
pub by_type: HashMap<LeagueFileKind, usize>,
31+
}
32+
2333
enum ChunkResult {
24-
Extracted,
34+
Extracted(LeagueFileKind, u64),
2535
SkippedFilter,
2636
SkippedExisting,
2737
}
@@ -62,7 +72,7 @@ impl<'a> Extractor<'a> {
6272
extract_directory: impl AsRef<Utf8Path>,
6373
filter_type: Option<&[LeagueFileKind]>,
6474
overwrite: bool,
65-
) -> eyre::Result<(usize, usize)> {
75+
) -> eyre::Result<ExtractStats> {
6676
let extract_directory = extract_directory.as_ref().to_path_buf();
6777

6878
let chunks: Vec<WadChunk> = self.wad.chunks().iter().copied().collect();
@@ -85,6 +95,9 @@ impl<'a> Extractor<'a> {
8595
let counter = AtomicUsize::new(0);
8696
let extracted_counter = AtomicUsize::new(0);
8797
let skipped_existing_counter = AtomicUsize::new(0);
98+
let error_counter = AtomicUsize::new(0);
99+
let bytes_written_counter = AtomicU64::new(0);
100+
let by_type: DashMap<LeagueFileKind, AtomicUsize> = DashMap::new();
88101
let filter_invert = self.filter_invert;
89102
let extract_dir = &extract_directory;
90103
let err_holder: std::sync::Mutex<Option<eyre::Report>> = std::sync::Mutex::new(None);
@@ -97,6 +110,9 @@ impl<'a> Extractor<'a> {
97110
let counter = &counter;
98111
let extracted_counter = &extracted_counter;
99112
let skipped_existing_counter = &skipped_existing_counter;
113+
let error_counter = &error_counter;
114+
let bytes_written_counter = &bytes_written_counter;
115+
let by_type = &by_type;
100116
let err_holder = &err_holder;
101117
let progress_span = &span;
102118

@@ -112,14 +128,20 @@ impl<'a> Extractor<'a> {
112128
);
113129

114130
match result {
115-
std::result::Result::Ok(ChunkResult::Extracted) => {
131+
std::result::Result::Ok(ChunkResult::Extracted(kind, size)) => {
116132
extracted_counter.fetch_add(1, Ordering::Relaxed);
133+
bytes_written_counter.fetch_add(size, Ordering::Relaxed);
134+
by_type
135+
.entry(kind)
136+
.or_insert_with(|| AtomicUsize::new(0))
137+
.fetch_add(1, Ordering::Relaxed);
117138
}
118139
std::result::Result::Ok(ChunkResult::SkippedExisting) => {
119140
skipped_existing_counter.fetch_add(1, Ordering::Relaxed);
120141
}
121142
std::result::Result::Ok(ChunkResult::SkippedFilter) => {}
122143
Err(e) => {
144+
error_counter.fetch_add(1, Ordering::Relaxed);
123145
let mut guard = err_holder.lock().unwrap();
124146
if guard.is_none() {
125147
*guard = Some(e);
@@ -185,10 +207,18 @@ impl<'a> Extractor<'a> {
185207
return Err(err);
186208
}
187209

188-
Ok((
189-
extracted_counter.load(Ordering::Relaxed),
190-
skipped_existing_counter.load(Ordering::Relaxed),
191-
))
210+
let by_type_map: HashMap<LeagueFileKind, usize> = by_type
211+
.into_iter()
212+
.map(|(k, v)| (k, v.load(Ordering::Relaxed)))
213+
.collect();
214+
215+
Ok(ExtractStats {
216+
extracted_count: extracted_counter.load(Ordering::Relaxed),
217+
skipped_existing: skipped_existing_counter.load(Ordering::Relaxed),
218+
error_count: error_counter.load(Ordering::Relaxed),
219+
bytes_written: bytes_written_counter.load(Ordering::Relaxed),
220+
by_type: by_type_map,
221+
})
192222
}
193223
}
194224

@@ -220,8 +250,14 @@ fn process_chunk(
220250
fs::create_dir_all(parent.as_std_path())?;
221251
}
222252

253+
let size = chunk_data.len() as u64;
223254
match write_chunk_file(full_path.as_std_path(), &chunk_data, overwrite) {
224-
std::result::Result::Ok(result) => return Ok(result),
255+
std::result::Result::Ok(ChunkWriteResult::Written) => {
256+
return Ok(ChunkResult::Extracted(chunk_kind, size));
257+
}
258+
std::result::Result::Ok(ChunkWriteResult::SkippedExisting) => {
259+
return Ok(ChunkResult::SkippedExisting);
260+
}
225261
Err(error) if error.kind() == io::ErrorKind::InvalidFilename => {
226262
return write_long_filename_chunk(
227263
chunk,
@@ -241,27 +277,32 @@ fn process_chunk(
241277
}
242278
}
243279

280+
enum ChunkWriteResult {
281+
Written,
282+
SkippedExisting,
283+
}
284+
244285
/// Writes chunk data to a file. When `overwrite` is false, uses `create_new(true)` for an
245286
/// atomic existence check, returning `SkippedExisting` on `AlreadyExists`. This avoids the
246287
/// TOCTOU race of a separate exists() check followed by write().
247288
fn write_chunk_file(
248289
path: &std::path::Path,
249290
data: &[u8],
250291
overwrite: bool,
251-
) -> io::Result<ChunkResult> {
292+
) -> io::Result<ChunkWriteResult> {
252293
if overwrite {
253294
fs::write(path, data)?;
254-
return std::result::Result::Ok(ChunkResult::Extracted);
295+
return std::result::Result::Ok(ChunkWriteResult::Written);
255296
}
256297

257298
match OpenOptions::new().write(true).create_new(true).open(path) {
258299
std::result::Result::Ok(mut file) => {
259300
file.write_all(data)?;
260-
std::result::Result::Ok(ChunkResult::Extracted)
301+
std::result::Result::Ok(ChunkWriteResult::Written)
261302
}
262303
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
263304
tracing::debug!("skipping existing file: {}", path.display());
264-
std::result::Result::Ok(ChunkResult::SkippedExisting)
305+
std::result::Result::Ok(ChunkWriteResult::SkippedExisting)
265306
}
266307
Err(e) => Err(e),
267308
}
@@ -358,11 +399,11 @@ fn write_long_filename_chunk(
358399
&hashed_path
359400
);
360401

361-
Ok(write_chunk_file(
362-
full_path.as_std_path(),
363-
chunk_data,
364-
overwrite,
365-
)?)
402+
let size = chunk_data.len() as u64;
403+
match write_chunk_file(full_path.as_std_path(), chunk_data, overwrite)? {
404+
ChunkWriteResult::Written => Ok(ChunkResult::Extracted(chunk_kind, size)),
405+
ChunkWriteResult::SkippedExisting => Ok(ChunkResult::SkippedExisting),
406+
}
366407
}
367408

368409
#[cfg(test)]

crates/wadtools/src/main.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ pub enum Commands {
130130
/// Overwrite existing files (default: skip existing)
131131
#[arg(long)]
132132
overwrite: bool,
133+
134+
/// Show summary statistics after extraction
135+
#[arg(short = 's', long, default_value_t = true)]
136+
stats: bool,
133137
},
134138
/// Compare two wad files
135139
///
@@ -254,6 +258,7 @@ fn main() -> eyre::Result<()> {
254258
filter_invert,
255259
list_filters,
256260
overwrite,
261+
stats,
257262
} => {
258263
if list_filters {
259264
print_supported_filters();
@@ -276,6 +281,7 @@ fn main() -> eyre::Result<()> {
276281
hash: hash_filter.clone(),
277282
filter_invert,
278283
overwrite,
284+
show_stats: stats,
279285
},
280286
&ht,
281287
)?;

0 commit comments

Comments
 (0)