use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use crate::error::{Error, Result};
use super::workload::{DirEntry, Entry, Workload};
#[cfg(unix)]
fn mode_of(metadata: &fs::Metadata) -> Option<u32> {
use std::os::unix::fs::PermissionsExt;
Some(metadata.permissions().mode())
}
#[cfg(not(unix))]
fn mode_of(_metadata: &fs::Metadata) -> Option<u32> {
None
}
pub const DEFAULT_SMALL_FILE_THRESHOLD: u64 = 256 * 1024;
pub(crate) async fn scan(root: &Path, threshold: u64) -> Result<Workload> {
let root = root.to_path_buf();
tokio::task::spawn_blocking(move || scan_blocking(&root, threshold))
.await
.expect("scan blocking task panicked")
}
fn scan_blocking(root: &Path, threshold: u64) -> Result<Workload> {
let metadata = fs::metadata(root).map_err(|e| classify_io_error(e, root.to_path_buf()))?;
if metadata.is_file() {
let relative_path = root
.file_name()
.map(PathBuf::from)
.unwrap_or_else(|| root.to_path_buf());
let entry = Entry {
path: root.to_path_buf(),
relative_path,
size: metadata.len(),
modified: metadata.modified().ok(),
};
return Ok(Workload::partition(vec![entry], threshold));
}
let mut entries = Vec::new();
let mut directories = Vec::new();
for result in WalkDir::new(root).into_iter() {
let walk_entry = result.map_err(classify_walkdir_error)?;
let file_type = walk_entry.file_type();
let relative_path = walk_entry
.path()
.strip_prefix(root)
.unwrap_or_else(|_| walk_entry.path())
.to_path_buf();
if file_type.is_dir() {
let dir_metadata = walk_entry.metadata().map_err(classify_walkdir_error)?;
directories.push(DirEntry {
path: walk_entry.path().to_path_buf(),
relative_path,
mode: mode_of(&dir_metadata),
});
continue;
}
if !file_type.is_file() {
continue;
}
let file_metadata = walk_entry.metadata().map_err(classify_walkdir_error)?;
entries.push(Entry {
path: walk_entry.path().to_path_buf(),
relative_path,
size: file_metadata.len(),
modified: file_metadata.modified().ok(),
});
}
let mut workload = Workload::partition(entries, threshold);
workload.directories = directories;
Ok(workload)
}
fn classify_io_error(err: io::Error, path: PathBuf) -> Error {
match err.kind() {
io::ErrorKind::NotFound => Error::SourceNotFound { path },
io::ErrorKind::PermissionDenied => Error::PermissionDenied { path },
io::ErrorKind::StorageFull => Error::NoSpace {
needed: 0,
available: 0,
},
_ => Error::Io { path, source: err },
}
}
fn classify_walkdir_error(err: walkdir::Error) -> Error {
let path = err.path().map(|p| p.to_path_buf());
match err.into_io_error() {
Some(io_err) => classify_io_error(io_err, path.unwrap_or_default()),
None => Error::Io {
path: path.unwrap_or_default(),
source: io::Error::other("directory walk error"),
},
}
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use super::*;
#[tokio::test]
async fn single_file_root_produces_one_entry_workload() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("only.txt");
fs::write(&file_path, vec![0u8; 100]).unwrap();
let workload = scan(&file_path, 256).await.unwrap();
assert_eq!(workload.small.len() + workload.large.len(), 1);
let entry = &workload.small[0];
assert_eq!(entry.path, file_path);
assert_eq!(entry.relative_path, PathBuf::from("only.txt"));
assert_eq!(entry.size, 100);
}
#[tokio::test]
async fn directory_with_mixed_sizes_classifies_correctly() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("small.txt"), vec![0u8; 10]).unwrap();
fs::create_dir(dir.path().join("nested")).unwrap();
fs::write(dir.path().join("nested").join("large.txt"), vec![0u8; 1000]).unwrap();
let workload = scan(dir.path(), 256).await.unwrap();
assert_eq!(workload.small.len(), 1);
assert_eq!(workload.small[0].relative_path, PathBuf::from("small.txt"));
assert_eq!(workload.small[0].size, 10);
assert_eq!(workload.large.len(), 1);
assert_eq!(
workload.large[0].relative_path,
PathBuf::from("nested").join("large.txt")
);
assert_eq!(workload.large[0].size, 1000);
}
#[tokio::test]
async fn empty_directory_produces_empty_workload() {
let dir = tempdir().unwrap();
let workload = scan(dir.path(), 256).await.unwrap();
assert_eq!(workload.small.len() + workload.large.len(), 0);
}
#[tokio::test]
async fn directory_of_only_subdirectories_produces_empty_workload() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("a").join("b").join("c")).unwrap();
let workload = scan(dir.path(), 256).await.unwrap();
assert_eq!(workload.small.len() + workload.large.len(), 0);
}
#[tokio::test]
async fn nonexistent_path_returns_error_instead_of_panicking_or_empty_workload() {
let dir = tempdir().unwrap();
let missing = dir.path().join("does-not-exist");
let result = scan(&missing, 256).await;
assert!(matches!(result, Err(Error::SourceNotFound { .. })));
}
#[cfg(unix)]
#[tokio::test]
async fn symlinks_are_skipped_not_followed() {
let dir = tempdir().unwrap();
let target = dir.path().join("real.txt");
fs::write(&target, vec![0u8; 10]).unwrap();
let link = dir.path().join("link.txt");
std::os::unix::fs::symlink(&target, &link).unwrap();
let workload = scan(dir.path(), 256).await.unwrap();
assert_eq!(workload.small.len() + workload.large.len(), 1);
assert_eq!(workload.small[0].relative_path, PathBuf::from("real.txt"));
}
#[cfg(unix)]
#[tokio::test]
async fn directory_mode_is_captured() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let subdir = dir.path().join("nested");
fs::create_dir(&subdir).unwrap();
fs::set_permissions(&subdir, fs::Permissions::from_mode(0o700)).unwrap();
let workload = scan(dir.path(), 256).await.unwrap();
let nested = workload
.directories
.iter()
.find(|d| d.relative_path == Path::new("nested"))
.expect("nested directory should have been captured");
assert_eq!(nested.mode.unwrap() & 0o7777, 0o700);
}
#[tokio::test]
async fn scanned_root_appears_in_directories_with_empty_relative_path() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("a.txt"), b"a").unwrap();
let workload = scan(dir.path(), 256).await.unwrap();
assert!(workload
.directories
.iter()
.any(|d| d.relative_path == PathBuf::new()));
}
#[tokio::test]
async fn single_file_root_produces_no_directories() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("only.txt");
fs::write(&file_path, b"hello").unwrap();
let workload = scan(&file_path, 256).await.unwrap();
assert!(workload.directories.is_empty());
}
#[tokio::test]
async fn empty_directory_produces_one_directory_entry_for_the_root() {
let dir = tempdir().unwrap();
let workload = scan(dir.path(), 256).await.unwrap();
assert_eq!(workload.directories.len(), 1);
assert_eq!(workload.directories[0].relative_path, PathBuf::new());
}
}