use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::core::ignore::global_ignore;
use crate::core::types::{RiskAnalysis, ScanFoundFolder, ScanOptions};
pub const MAX_WORKERS: usize = 8;
#[derive(Debug, Default)]
pub struct ScanStats {
pub completed: AtomicU64,
pub found: AtomicU64,
}
pub struct ScannerHandle {
pub results: mpsc::Receiver<ScanFoundFolder>,
pub cancel: CancellationToken,
pub stats: Arc<ScanStats>,
}
impl Drop for ScannerHandle {
fn drop(&mut self) {
self.cancel.cancel();
}
}
struct ScanConfig {
targets: Vec<String>,
exclude: Vec<String>,
perform_risk: bool,
}
impl From<ScanOptions> for ScanConfig {
fn from(o: ScanOptions) -> Self {
Self { targets: o.targets, exclude: o.exclude, perform_risk: o.perform_risk_analysis }
}
}
enum Job {
Explore(PathBuf),
}
#[derive(Clone)]
struct WorkerHandles {
tx_dispatch: mpsc::UnboundedSender<Job>,
tx_results: mpsc::Sender<ScanFoundFolder>,
cancel: CancellationToken,
stats: Arc<ScanStats>,
pending: Arc<AtomicUsize>,
cfg: Arc<ScanConfig>,
}
pub fn start_scan(root: PathBuf, opts: ScanOptions) -> ScannerHandle {
let cancel = CancellationToken::new();
let stats = Arc::new(ScanStats::default());
let cfg = Arc::new(ScanConfig::from(opts));
let pending = Arc::new(AtomicUsize::new(0));
let (result_tx, result_rx) = mpsc::channel::<ScanFoundFolder>(1024);
let (job_tx, job_rx) = mpsc::unbounded_channel::<Job>();
let n = optimal_workers();
let mut worker_inputs = Vec::with_capacity(n);
for _ in 0..n {
let (wtx, wrx) = mpsc::unbounded_channel::<Job>();
worker_inputs.push(wtx);
let h = WorkerHandles {
tx_dispatch: job_tx.clone(),
tx_results: result_tx.clone(),
cancel: cancel.clone(),
stats: stats.clone(),
pending: pending.clone(),
cfg: cfg.clone(),
};
tokio::spawn(worker_loop(wrx, h));
}
tokio::spawn(dispatcher(job_rx, worker_inputs, cancel.clone()));
pending.fetch_add(1, Ordering::SeqCst);
if job_tx.send(Job::Explore(root)).is_err() {
debug_assert!(false, "scanner: seed job rejected at construction");
if pending.fetch_sub(1, Ordering::SeqCst) == 1 {
cancel.cancel();
}
}
drop(result_tx);
drop(job_tx);
ScannerHandle { results: result_rx, cancel, stats }
}
fn optimal_workers() -> usize {
num_cpus::get().clamp(1, MAX_WORKERS)
}
async fn dispatcher(
mut rx: mpsc::UnboundedReceiver<Job>,
outs: Vec<mpsc::UnboundedSender<Job>>,
cancel: CancellationToken,
) {
let mut idx = 0usize;
loop {
tokio::select! {
biased;
_ = cancel.cancelled() => break,
job = rx.recv() => match job {
None => break,
Some(job) => {
let target = &outs[idx];
idx = (idx + 1) % outs.len();
if target.send(job).is_err() {
break;
}
}
}
}
}
}
async fn worker_loop(mut rx: mpsc::UnboundedReceiver<Job>, h: WorkerHandles) {
loop {
tokio::select! {
biased;
_ = h.cancel.cancelled() => break,
job = rx.recv() => match job {
None => break,
Some(Job::Explore(path)) => explore_dir(path, &h).await,
}
}
}
}
async fn explore_dir(path: PathBuf, h: &WorkerHandles) {
let mut rd = match tokio::fs::read_dir(&path).await {
Ok(r) => r,
Err(_) => {
decrement_pending(h);
return;
}
};
loop {
if h.cancel.is_cancelled() {
break;
}
let entry = match rd.next_entry().await {
Ok(Some(e)) => e,
Ok(None) => break,
Err(_) => continue,
};
let name = entry.file_name();
let name_str = name.to_string_lossy();
let ft = match entry.file_type().await {
Ok(t) => t,
Err(_) => continue,
};
if ft.is_symlink() || !ft.is_dir() {
continue;
}
let is_target = h.cfg.targets.iter().any(|t| t.as_str() == name_str.as_ref());
let is_global_ignored = global_ignore().contains(name_str.as_ref());
if is_global_ignored && !is_target {
continue;
}
let subpath = path.join(&name);
let subpath_str = subpath.to_string_lossy();
if h.cfg.exclude.iter().any(|ex| subpath_str.contains(ex.as_str())) {
continue;
}
if is_target {
let risk = h.cfg.perform_risk.then(RiskAnalysis::safe);
tokio::select! {
biased;
_ = h.cancel.cancelled() => break,
_ = h.tx_results.send(ScanFoundFolder::new(subpath, risk)) => {}
}
h.stats.found.fetch_add(1, Ordering::SeqCst);
} else {
h.pending.fetch_add(1, Ordering::SeqCst);
if h.tx_dispatch.send(Job::Explore(subpath)).is_err() {
if h.pending.fetch_sub(1, Ordering::SeqCst) == 1 {
h.cancel.cancel();
}
}
}
}
h.stats.completed.fetch_add(1, Ordering::SeqCst);
decrement_pending(h);
}
fn decrement_pending(h: &WorkerHandles) {
if h.pending.fetch_sub(1, Ordering::SeqCst) == 1 {
h.cancel.cancel();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn optimal_workers_in_range() {
let n = optimal_workers();
assert!((1..=MAX_WORKERS).contains(&n));
}
#[test]
fn scan_config_from_options_preserves_fields() {
let opts = ScanOptions {
targets: vec!["node_modules".into()],
exclude: vec!["skip".into()],
sort_by: None,
perform_risk_analysis: false,
};
let cfg = ScanConfig::from(opts);
assert_eq!(cfg.targets, vec!["node_modules"]);
assert_eq!(cfg.exclude, vec!["skip"]);
assert!(!cfg.perform_risk);
}
#[test]
fn scan_stats_starts_zeroed() {
let s = ScanStats::default();
assert_eq!(s.completed.load(Ordering::SeqCst), 0);
assert_eq!(s.found.load(Ordering::SeqCst), 0);
}
}