Skip to main content

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}