ferris_files/lib.rs
1use filesize::PathExt;
2use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
3use rayon::prelude::*;
4use std::collections::{HashSet, VecDeque};
5use std::error::Error;
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
8use std::sync::mpsc::{self, Sender};
9use std::sync::{Arc, Mutex};
10use std::{fs, io, thread};
11
12pub mod traits;
13use crate::traits::ByteSize;
14
15pub mod errors;
16use crate::errors::SearchError;
17
18pub mod config;
19use crate::config::Config;
20
21pub mod top_entries;
22use crate::top_entries::TopEntries;
23
24pub mod args;
25
26pub mod tests;
27
28/// Represents a file system entry with its path and processing result.
29#[derive(Debug)]
30struct FileEntry {
31 path: PathBuf,
32 result: Result<(), SearchError>,
33}
34
35/// Returns a platform specific (Windows or Unix) cap on open file handles.
36/// On Unix will return 50% of the system's limit.
37/// Windows uses a RAM based approach to allocate 64 file descriptors per 1GB of RAM.
38fn get_fd_limit() -> usize {
39 #[cfg(unix)]
40 {
41 use libc::{rlimit, RLIMIT_NOFILE};
42 let mut rlim = rlimit {
43 rlim_cur: 0,
44 rlim_max: 0,
45 };
46 // Add some debug printing
47 let result = unsafe { libc::getrlimit(RLIMIT_NOFILE, &mut rlim) };
48 if result == 0 {
49 let limit = rlim.rlim_cur as usize;
50 return limit / 2;
51 } else {
52 // Print the error if getrlimit fails
53 println!("Error: {}", std::io::Error::last_os_error());
54 }
55 }
56
57 #[cfg(windows)]
58 {
59 // Try to get system memory info to make an educated guess
60 use windows_sys::Win32::System::SystemInformation::GetPhysicallyInstalledSystemMemory;
61 let mut memory_kb: u64 = 0;
62 if unsafe { GetPhysicallyInstalledSystemMemory(&mut memory_kb) } != 0 {
63 let memory_gb = memory_kb / (1024 * 1024);
64 // Scale based on available memory, but cap at reasonable limits
65 return usize::min(usize::max(512, (memory_gb * 64) as usize), 8192);
66 }
67 // Fallback for Windows
68 return 2048;
69 }
70
71 // Default fallback
72 100
73}
74
75/// Processes a batch of file entries and updates the top_entries collection.
76///
77/// This function processes each file entry in parallel, collecting metadata and file sizes.
78/// It handles various error conditions (IO errors, invalid paths, mutex lock failures)
79/// while maintaining a count of successful and failed operations.
80///
81/// # Arguments
82///
83/// * `batch` - Vector of file entries to process. Each entry contains a path and its current processing status
84/// * `top_entries` - Thread-safe collection that maintains the N largest files found so far
85/// * `error_log` - Thread-safe collection that maintains a record of any errors that occurr
86/// * `is_verbose` - A bool used to log error messages if true
87///
88/// # Returns
89///
90/// Returns a tuple of `(processed, total)` where:
91/// * `processed` - Number of files successfully processed and added to top_entries
92/// * `total` - Total number of files attempted to process
93///
94/// # Error Handling
95///
96/// The function logs errors when is_verbose is true but does not propagate errors for:
97/// * File metadata access failures
98/// * File size calculation failures
99/// * Invalid UTF-8 in path names
100/// * Mutex lock failures
101///
102/// # Implementation Details
103///
104/// * Uses parallel iteration for metadata collection
105/// * Metadata collection is skipped on entry.result Err variant
106/// * Maintains a thread-safe ordering of largest files
107fn process_batch(
108 batch: Vec<FileEntry>,
109 top_entries: &Arc<Mutex<TopEntries>>,
110 error_log: Arc<Mutex<Vec<String>>>,
111 is_verbose: bool,
112) -> (usize, usize) {
113 let metadata_results: Vec<_> = batch
114 .into_par_iter()
115 .map(|entry| match entry.result {
116 Ok(()) => match fs::metadata(&entry.path) {
117 Ok(metadata) => Some((entry.path, Ok(metadata))),
118 Err(err) => Some((entry.path, Err(err))),
119 },
120 Err(err) => Some((
121 entry.path,
122 Err(io::Error::new(
123 io::ErrorKind::Other,
124 format!("Previous error: {:?}", err),
125 )),
126 )),
127 })
128 .collect();
129
130 let total = metadata_results.len();
131 let mut processed = 0;
132 let mut errors = Vec::new();
133
134 for result in metadata_results {
135 if let Some((path, metadata_result)) = result {
136 match metadata_result {
137 Ok(metadata) => match path.size_on_disk_fast(&metadata) {
138 Ok(size) => {
139 if let Some(path_str) = path.to_str() {
140 match top_entries.lock() {
141 Ok(mut top) => {
142 top.insert(path_str.to_string(), size);
143 processed += 1;
144 }
145 Err(err) => {
146 errors.push(format!(
147 "Failed to lock top_entries for {}: {}",
148 path.display(),
149 err
150 ));
151 }
152 }
153 } else {
154 errors.push(format!("Invalid UTF-8 in path: {}", path.display()));
155 }
156 }
157 Err(err) => {
158 errors.push(format!(
159 "Failed to get size for {}: {}",
160 path.display(),
161 err
162 ));
163 }
164 },
165 Err(err) => {
166 errors.push(format!("Error processing {}: {}", path.display(), err));
167 }
168 }
169 }
170 }
171
172 // Log errors if any occurred
173 if !errors.is_empty() && is_verbose {
174 error_log.lock().unwrap().extend(errors);
175 }
176
177 (processed, total)
178}
179
180/// Performs a parallel search of files in a directory tree, sending batches of file paths to a channel.
181///
182/// # Arguments
183///
184/// * `root_dir` - The root directory to start the search from
185/// * `tx` - A channel sender to transmit batches of discovered file paths
186/// * `config` - Arc reference to a config instance
187/// * `error_log` - Thread safe collection of errors ocurring during runtime
188///
189/// # Returns
190///
191/// Returns an `io::Result<(), SearchError>` indicating whether the operation completed successfully
192/// or if a SearchError occurred
193///
194/// # Details
195///
196/// This function performs parallel directory traversal that:
197/// - Uses multiple threads (based on available CPU cores) to search directories recursively
198/// - Manages a shared work queue for distributing directory scanning work
199/// - Limits the number of simultaneously open file handles to a platofrm specific limit or default of 100
200/// - Skips symbolic links and non-existent paths
201/// - Respects a set of directories to exclude from scanning
202/// - Batches results to reduce channel communication overhead
203///
204fn parallel_search(
205 root_dir: &Path,
206 tx: Sender<Vec<FileEntry>>,
207 progress: ProgressBar,
208 config: Arc<Config>,
209 error_log: Arc<Mutex<Vec<String>>>,
210) -> Result<(), SearchError> {
211 let work_queue = Arc::new(Mutex::new(VecDeque::new()));
212 let is_scanning = Arc::new(AtomicBool::new(true));
213
214 // Canonicalize directories to ignore
215 let skip_dirs: HashSet<PathBuf> = config
216 .skip_dirs
217 .iter()
218 .filter_map(|dir| match PathBuf::from(dir).canonicalize() {
219 Ok(path) => Some(path),
220 Err(err) => {
221 if config.verbose {
222 error_log.lock().unwrap().push(format!(
223 "Warning: Could not canonicalize skip directory '{}': {}",
224 dir, err
225 ));
226 }
227
228 None
229 }
230 })
231 .collect();
232
233 // Initialize work queue with root directory
234 match root_dir.canonicalize() {
235 Ok(root) => work_queue.lock().unwrap().push_back(root),
236 Err(err) => {
237 if config.verbose {
238 error_log
239 .lock()
240 .unwrap()
241 .push(format!("Failed to canonicalize root directory: {}", err));
242 }
243 }
244 }
245
246 let mut handles = vec![];
247 let open_files = Arc::new(AtomicUsize::new(0));
248 let errors_count = Arc::new(AtomicUsize::new(0));
249
250 for _ in 0..config.num_threads {
251 let work_queue = Arc::clone(&work_queue);
252 let tx = tx.clone();
253 let progress = progress.clone();
254 let open_files = Arc::clone(&open_files);
255 let is_scanning = Arc::clone(&is_scanning);
256 let skip_dirs = skip_dirs.clone();
257 let errors_count = Arc::clone(&errors_count);
258 let config_clone = config.clone();
259 let error_log = error_log.clone();
260
261 handles.push(thread::spawn(move || -> Result<(), SearchError> {
262 let mut batch = Vec::with_capacity(config_clone.batch_size);
263
264 'outer: loop {
265 let dir = {
266 match work_queue.lock() {
267 Ok( mut q) => {
268 q.pop_front()
269 }
270 Err(e) => {
271 if config_clone.verbose {
272 error_log
273 .lock()
274 .unwrap()
275 .push(format!("Failed to lock work queue: {}", e));
276 }
277 None
278 }
279 }
280 };
281
282 match dir {
283 Some(dir) => {
284 progress.set_message(format!("Scanning: {}", dir.display()));
285
286 // Check if directory should be skipped
287 match dir.canonicalize() {
288 Ok(canonical_dir) => {
289 if skip_dirs
290 .iter()
291 .any(|skip_dir| canonical_dir.starts_with(skip_dir))
292 {
293 continue;
294 }
295 }
296 Err(e) => {
297 if config_clone.verbose {
298 error_log.lock().unwrap().push(format!("Failed to canonicalize directory {:#?} : {}", dir, e));
299 }
300 }
301 }
302
303 // Wait for available file handle with timeout
304 let mut wait_time = 1;
305 while open_files.load(Ordering::Relaxed) >= config_clone.max_open_files {
306 thread::sleep(std::time::Duration::from_millis(wait_time));
307 wait_time = wait_time.saturating_mul(2).min(100); // Exponential backoff
308 }
309 open_files.fetch_add(1, Ordering::SeqCst);
310
311 match fs::read_dir(&dir) {
312 Ok(entries) => {
313 for entry in entries.flatten() {
314 let path = entry.path();
315 if path.is_symlink() {
316 continue;
317 }
318
319 let file_entry = match path.metadata() {
320 Ok(metadata) => {
321 if metadata.is_dir() {
322 match work_queue.lock() {
323 Ok(mut q) => {
324 q.push_back(path);
325 }
326 Err(e) => {
327 if config_clone.verbose {
328 error_log.lock().unwrap().push(format!("Error obtaining lock on work queue: {}", e));
329 }
330 }
331 }
332 continue;
333 }
334 FileEntry {
335 path,
336 result: Ok(()),
337 }
338 }
339 Err(err) => {
340 errors_count.fetch_add(1, Ordering::Relaxed);
341 FileEntry {
342 path,
343 result: Err(SearchError::IoError(err)),
344 }
345 }
346 };
347
348 batch.push(file_entry);
349 if batch.len() >= config_clone.batch_size {
350 tx.send(batch).map_err(|e| {
351 SearchError::SendError(format!(
352 "Failed to send batch: {}",
353 e
354 ))
355 })?;
356 batch = Vec::with_capacity(config_clone.batch_size);
357 }
358 }
359 }
360 Err(err) => {
361 errors_count.fetch_add(1, Ordering::Relaxed);
362 if config_clone.verbose {
363 error_log.lock().unwrap().push(format!("Error reading directory {}: {}", dir.display(), err));
364 }
365 }
366 }
367
368 open_files.fetch_sub(1, Ordering::SeqCst);
369 }
370 None => {
371 if !is_scanning.load(Ordering::SeqCst) {
372 if work_queue
373 .lock()
374 .map_err(|e| {
375 SearchError::ThreadError(format!(
376 "Failed to lock work queue: {}",
377 e
378 ))
379 })?
380 .is_empty()
381 {
382 break 'outer;
383 }
384 }
385 thread::sleep(std::time::Duration::from_millis(10));
386 }
387 }
388 }
389
390 if !batch.is_empty() {
391 tx.send(batch).map_err(|e| {
392 SearchError::SendError(format!("Failed to send final batch: {}", e))
393 })?;
394 }
395
396 Ok(())
397 }));
398 }
399
400 is_scanning.store(false, Ordering::SeqCst);
401
402 // Join threads and collect errors
403 let thread_results: Vec<Result<(), SearchError>> = handles
404 .into_iter()
405 .map(|handle| {
406 handle
407 .join()
408 .map_err(|e| SearchError::ThreadError(format!("Thread panicked: {:?}", e)))?
409 })
410 .collect();
411
412 // Check for any thread errors
413 for result in thread_results {
414 if let Err(err) = result {
415 if config.verbose {
416 error_log
417 .lock()
418 .unwrap()
419 .push(format!("Thread error: {:?}", err));
420 }
421 }
422 }
423
424 let error_count = errors_count.load(Ordering::Relaxed);
425 progress.finish_with_message(format!(
426 "Directory scan complete ({} errors encountered: run with -v for details)",
427 error_count
428 ));
429
430 Ok(())
431}
432
433/// Responsible for initiating the directory traversdal and analyzing files as they are discovered
434///
435/// # Arguments
436///
437/// * `config` - An instance of a `Config` struct
438///
439/// # Returns
440///
441/// * `Result<(), Box<dyn Error>>` - Ok(()) if successful, or an Error if something fails
442///
443/// # Progress Display
444///
445/// The function shows two progress indicators:
446/// 1. A spinner showing the directory scanning progress
447/// 2. A spinner showing file processing progress with counts of total and successfully processed files
448///
449/// # Output
450///
451/// Upon completion, prints a list of the largest files found, with their paths and sizes.
452/// If verbsoity was enabled, errors will be printed before file size results.
453///
454/// # Implementation Details
455///
456/// - Uses a channel (`mpsc`) for communication between scanner and processor threads
457/// - Maintains thread-safe access to the top entries using `Arc<Mutex<TopEntries>>`
458/// - Processes files in batches for better performance
459/// - Shows real-time progress using the `indicatif` crate's progress bars
460///
461pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
462 let is_verbose = config.verbose;
463 let error_log: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
464 let error_log_clone = error_log.clone();
465 let config_arc: Arc<Config> = Arc::new(config.clone());
466
467 print!(
468 "Searching for {0} largest entries in {1}:\n",
469 config.num_entries,
470 config.root_path.display()
471 );
472
473 let multi_progress = MultiProgress::new();
474 let scan_progress = multi_progress.add(ProgressBar::new_spinner());
475 scan_progress.set_style(
476 ProgressStyle::default_spinner()
477 .template("{spinner:.green} [{elapsed_precise}] {msg}")
478 .unwrap(),
479 );
480
481 let process_progress = multi_progress.add(ProgressBar::new_spinner());
482 process_progress.set_style(
483 ProgressStyle::default_spinner()
484 .template("{spinner:.green} [{elapsed_precise}] {msg}")
485 .unwrap(),
486 );
487
488 let (tx, rx) = mpsc::channel();
489 let top_entries = Arc::new(Mutex::new(TopEntries::new(config.num_entries)));
490
491 // Directory scanner thread
492 let root_path = config.root_path.clone();
493 let scan_handle = thread::spawn(move || {
494 parallel_search(
495 &root_path,
496 tx,
497 scan_progress,
498 config_arc.clone(),
499 error_log_clone.clone(),
500 )
501 });
502
503 // Process files as received
504 let mut total_files = 0;
505 let mut total_processed = 0;
506 let mut total_attempts = 0;
507
508 while let Ok(batch) = rx.recv() {
509 total_files += batch.len();
510 let (processed, attempted) =
511 process_batch(batch, &top_entries, error_log.clone(), is_verbose);
512 total_processed += processed;
513 total_attempts += attempted;
514
515 process_progress.set_message(format!(
516 "Processing {} files (successfully processed: {}, failed: {})...",
517 total_files,
518 total_processed,
519 total_attempts - total_processed
520 ));
521 }
522
523 // Handle scanner thread result
524 match scan_handle.join() {
525 Ok(result) => result.map_err(|e| Box::new(e))?,
526 Err(e) => {
527 if is_verbose {
528 error_log
529 .lock()
530 .unwrap()
531 .push(format!("Scanner thread panicked: {:?}", e));
532 }
533 }
534 }
535
536 process_progress.finish_with_message(format!(
537 "Processed {} files ({} successful, {} failed)",
538 total_attempts,
539 total_processed,
540 total_attempts - total_processed
541 ));
542
543 if is_verbose {
544 println!();
545 error_log.lock().unwrap().iter().for_each(|e| {
546 eprintln!("{}", e);
547 });
548 }
549
550 println!("\n");
551
552 match top_entries.lock() {
553 Ok(top) => {
554 if top.entries.is_empty() {
555 println!("No files found - run with -v flag for error output");
556 } else {
557 for (path, size) in top.entries.iter() {
558 println!("{}: {}", path, size.format_size());
559 }
560 }
561 }
562 Err(e) => {
563 return Err(Box::new(io::Error::new(
564 io::ErrorKind::Other,
565 format!("Failed to lock top entries for final output: {}", e),
566 )));
567 }
568 }
569
570 Ok(())
571}