use std::cmp::Ordering;
use std::collections::BTreeSet;
use std::env;
use std::ffi::OsStr;
use std::fs::{self, DirEntry, Metadata, OpenOptions};
use std::io::{self, BufRead};
use std::path::{Component, Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const USER_DIR_CONFIG_LINE_LIMIT: usize = 128;
#[derive(Clone, Debug)]
pub struct FileEntry {
pub name: String,
pub path: PathBuf,
pub kind: EntryKind,
pub size: u64,
pub modified: Option<SystemTime>,
pub permissions: String,
pub readonly: bool,
pub hidden: bool,
pub symlink_target: Option<PathBuf>,
}
impl FileEntry {
pub fn from_path(path: PathBuf) -> io::Result<Self> {
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
let metadata = fs::symlink_metadata(&path)?;
Ok(Self::from_metadata(path, name, metadata))
}
fn from_dir_entry(entry: DirEntry) -> io::Result<Self> {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
let metadata = fs::symlink_metadata(&path)?;
Ok(Self::from_metadata(path, name, metadata))
}
fn from_metadata(path: PathBuf, name: String, metadata: Metadata) -> Self {
let file_type = metadata.file_type();
let symlink_target = if file_type.is_symlink() {
fs::read_link(&path).ok()
} else {
None
};
let kind = if file_type.is_symlink() {
EntryKind::Symlink
} else if file_type.is_dir() {
EntryKind::Directory
} else if file_type.is_file() {
EntryKind::File
} else {
EntryKind::Other
};
Self {
hidden: name.starts_with('.'),
name,
path,
kind,
size: metadata.len(),
modified: metadata.modified().ok(),
permissions: permission_label(&metadata),
readonly: metadata.permissions().readonly(),
symlink_target,
}
}
pub fn extension(&self) -> String {
self.path
.extension()
.and_then(OsStr::to_str)
.unwrap_or_default()
.to_ascii_lowercase()
}
pub fn type_label(&self) -> &'static str {
match self.kind {
EntryKind::Directory => "Folder",
EntryKind::Symlink => "Link",
EntryKind::Other => "Special",
EntryKind::File => match self.extension().as_str() {
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" => "Image",
"mp3" | "flac" | "wav" | "ogg" | "m4a" => "Audio",
"mp4" | "mkv" | "mov" | "webm" | "avi" => "Video",
"rs" | "c" | "h" | "cpp" | "hpp" | "py" | "js" | "ts" | "tsx" | "jsx" | "go"
| "java" | "kt" | "swift" => "Code",
"md" | "txt" | "rst" | "log" => "Text",
"pdf" => "PDF",
"zip" | "tar" | "gz" | "xz" | "zst" | "7z" | "rar" => "Archive",
_ => "File",
},
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum EntryKind {
Directory,
File,
Symlink,
Other,
}
impl EntryKind {
fn order(self) -> u8 {
match self {
Self::Directory => 0,
Self::Symlink => 1,
Self::File => 2,
Self::Other => 3,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SortKey {
Name,
Modified,
Size,
Kind,
}
impl SortKey {
pub const ALL: [Self; 4] = [Self::Name, Self::Modified, Self::Size, Self::Kind];
pub fn label(self) -> &'static str {
match self {
Self::Name => "Name",
Self::Modified => "Modified",
Self::Size => "Size",
Self::Kind => "Kind",
}
}
}
#[derive(Clone, Debug)]
pub struct DirectorySnapshot {
pub path: PathBuf,
pub entries: Vec<FileEntry>,
pub total_entries: usize,
pub hidden_entries: usize,
pub directory_count: usize,
pub file_count: usize,
pub total_size: u64,
pub errors: Vec<String>,
pub scan_limited: bool,
pub error_limited: bool,
pub entry_limit: usize,
pub error_limit: usize,
}
#[derive(Clone, Copy, Debug)]
pub struct ScanOptions {
pub show_hidden: bool,
pub sort_key: SortKey,
pub reverse: bool,
pub folders_first: bool,
pub entry_limit: usize,
pub error_limit: usize,
}
#[derive(Clone, Debug)]
pub struct RecursiveSearchResults {
pub entries: Vec<FileEntry>,
pub visited_dirs: usize,
pub result_limit: usize,
pub dir_limit: usize,
pub queue_limit: usize,
pub result_limited: bool,
pub dir_limited: bool,
pub queue_limited: bool,
}
#[derive(Clone, Copy, Debug)]
pub struct RecursiveSearchOptions {
pub show_hidden: bool,
pub sort_key: SortKey,
pub reverse: bool,
pub folders_first: bool,
pub result_limit: usize,
pub dir_limit: usize,
pub queue_limit: usize,
}
impl RecursiveSearchResults {
pub fn limited(&self) -> bool {
self.result_limited || self.dir_limited || self.queue_limited
}
}
pub fn scan_directory(path: &Path, options: ScanOptions) -> io::Result<DirectorySnapshot> {
let entry_limit = options.entry_limit.max(1);
let error_limit = options.error_limit;
let mut entries = Vec::new();
let mut errors = Vec::new();
let mut total_entries = 0;
let mut hidden_entries = 0;
let mut directory_count = 0;
let mut file_count = 0;
let mut total_size: u64 = 0;
let mut scan_limited = false;
let mut error_limited = false;
for entry in fs::read_dir(path)? {
if total_entries >= entry_limit {
scan_limited = true;
break;
}
total_entries += 1;
match entry.and_then(FileEntry::from_dir_entry) {
Ok(entry) => {
if entry.hidden {
hidden_entries += 1;
if !options.show_hidden {
continue;
}
}
match entry.kind {
EntryKind::Directory => directory_count += 1,
EntryKind::File | EntryKind::Symlink | EntryKind::Other => file_count += 1,
}
total_size = total_size.saturating_add(entry.size);
entries.push(entry);
}
Err(error) => {
if errors.len() < error_limit {
errors.push(error.to_string());
} else {
error_limited = true;
}
}
}
}
sort_entries(
&mut entries,
options.sort_key,
options.reverse,
options.folders_first,
);
Ok(DirectorySnapshot {
path: path.to_path_buf(),
entries,
total_entries,
hidden_entries,
directory_count,
file_count,
total_size,
errors,
scan_limited,
error_limited,
entry_limit,
error_limit,
})
}
pub fn sort_entries(
entries: &mut [FileEntry],
sort_key: SortKey,
reverse: bool,
folders_first: bool,
) {
entries.sort_by(|left, right| {
if folders_first {
let kind_order = left.kind.order().cmp(&right.kind.order());
if kind_order != Ordering::Equal {
return kind_order;
}
}
let ordering = match sort_key {
SortKey::Name => compare_names(&left.name, &right.name),
SortKey::Modified => left.modified.cmp(&right.modified),
SortKey::Size => left.size.cmp(&right.size),
SortKey::Kind => left
.type_label()
.cmp(right.type_label())
.then_with(|| compare_names(&left.name, &right.name)),
};
if reverse {
ordering.reverse()
} else {
ordering
}
});
}
pub fn filter_entries(entries: &[FileEntry], query: &str) -> Vec<FileEntry> {
let needle = query.trim().to_ascii_lowercase();
if needle.is_empty() {
return entries.to_vec();
}
entries
.iter()
.filter(|entry| {
entry.name.to_ascii_lowercase().contains(&needle)
|| entry.extension().contains(&needle)
|| entry.type_label().to_ascii_lowercase().contains(&needle)
})
.cloned()
.collect()
}
pub fn recursive_search(
root: &Path,
query: &str,
options: RecursiveSearchOptions,
) -> RecursiveSearchResults {
recursive_search_cancellable(root, query, options, || false)
}
pub fn recursive_search_cancellable(
root: &Path,
query: &str,
options: RecursiveSearchOptions,
mut cancelled: impl FnMut() -> bool,
) -> RecursiveSearchResults {
let result_limit = options.result_limit.max(1);
let dir_limit = options.dir_limit.max(1);
let queue_limit = options.queue_limit.max(1);
let needle = query.trim().to_ascii_lowercase();
if needle.is_empty() {
return RecursiveSearchResults {
entries: Vec::new(),
visited_dirs: 0,
result_limit,
dir_limit,
queue_limit,
result_limited: false,
dir_limited: false,
queue_limited: false,
};
}
let mut results = Vec::new();
let mut stack = vec![root.to_path_buf()];
let mut visited_dirs = 0;
let mut result_limited = false;
let mut dir_limited = false;
let mut queue_limited = false;
'search: while let Some(path) = stack.pop() {
if cancelled() {
break;
}
if visited_dirs >= dir_limit {
dir_limited = true;
break;
}
visited_dirs += 1;
let Ok(read_dir) = fs::read_dir(&path) else {
continue;
};
for entry in read_dir.flatten() {
if cancelled() {
break 'search;
}
if results.len() >= result_limit {
result_limited = true;
sort_entries(
&mut results,
options.sort_key,
options.reverse,
options.folders_first,
);
return RecursiveSearchResults {
entries: results,
visited_dirs,
result_limit,
dir_limit,
queue_limit,
result_limited,
dir_limited,
queue_limited,
};
}
let Ok(file_entry) = FileEntry::from_dir_entry(entry) else {
continue;
};
if file_entry.hidden && !options.show_hidden {
continue;
}
if file_entry.kind == EntryKind::Directory {
if stack.len() < queue_limit {
stack.push(file_entry.path.clone());
} else {
queue_limited = true;
}
}
if file_entry.name.to_ascii_lowercase().contains(&needle)
|| file_entry.extension().contains(&needle)
|| file_entry
.type_label()
.to_ascii_lowercase()
.contains(&needle)
{
results.push(file_entry);
}
}
}
sort_entries(
&mut results,
options.sort_key,
options.reverse,
options.folders_first,
);
RecursiveSearchResults {
entries: results,
visited_dirs,
result_limit,
dir_limit,
queue_limit,
result_limited,
dir_limited,
queue_limited,
}
}
pub fn canonical_display_path(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
pub fn expand_tilde(input: &str) -> PathBuf {
let trimmed = input.trim();
if trimmed == "~" {
return home_dir();
}
if let Some(rest) = trimmed.strip_prefix("~/") {
return home_dir().join(rest);
}
PathBuf::from(trimmed)
}
pub fn home_dir() -> PathBuf {
env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from("/")))
}
#[derive(Clone, Debug)]
pub struct Place {
pub label: String,
pub path: PathBuf,
pub detail: String,
}
pub fn known_places(current_dir: &Path) -> Vec<Place> {
let home = home_dir();
let mut seen = BTreeSet::new();
let mut places = Vec::new();
push_place(&mut places, &mut seen, "Home", home.clone(), "USER ROOT");
push_place(
&mut places,
&mut seen,
"Desktop",
xdg_user_dir("XDG_DESKTOP_DIR", "Desktop"),
"WORKSPACE",
);
push_place(
&mut places,
&mut seen,
"Documents",
xdg_user_dir("XDG_DOCUMENTS_DIR", "Documents"),
"DOCS",
);
push_place(
&mut places,
&mut seen,
"Downloads",
xdg_user_dir("XDG_DOWNLOAD_DIR", "Downloads"),
"INBOX",
);
push_place(
&mut places,
&mut seen,
"Pictures",
xdg_user_dir("XDG_PICTURES_DIR", "Pictures"),
"MEDIA",
);
push_place(
&mut places,
&mut seen,
"Music",
xdg_user_dir("XDG_MUSIC_DIR", "Music"),
"AUDIO",
);
push_place(
&mut places,
&mut seen,
"Videos",
xdg_user_dir("XDG_VIDEOS_DIR", "Videos"),
"VIDEO",
);
push_place(
&mut places,
&mut seen,
"Filesystem",
PathBuf::from("/"),
"ROOT",
);
push_place(
&mut places,
&mut seen,
"Temp",
PathBuf::from("/tmp"),
"SCRATCH",
);
push_place(
&mut places,
&mut seen,
"Trash",
trash_files_dir(),
"LOCAL TRASH",
);
push_place(
&mut places,
&mut seen,
"Current",
current_dir.to_path_buf(),
"ACTIVE SLOT",
);
places
}
pub fn trash_files_dir() -> PathBuf {
xdg_data_home().join("Trash/files")
}
pub fn trash_info_dir() -> PathBuf {
xdg_data_home().join("Trash/info")
}
pub fn xdg_data_home() -> PathBuf {
env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home_dir().join(".local/share"))
}
fn push_place(
places: &mut Vec<Place>,
seen: &mut BTreeSet<PathBuf>,
label: &str,
path: PathBuf,
detail: &str,
) {
if !path.exists() && label != "Trash" {
return;
}
let normalized = canonical_display_path(&path);
if seen.insert(normalized) {
places.push(Place {
label: label.to_string(),
path,
detail: detail.to_string(),
});
}
}
fn xdg_user_dir(key: &str, fallback: &str) -> PathBuf {
if let Some(path) = parse_user_dirs_config(key) {
return path;
}
home_dir().join(fallback)
}
fn parse_user_dirs_config(key: &str) -> Option<PathBuf> {
let config_path = env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home_dir().join(".config"))
.join("user-dirs.dirs");
let file = fs::File::open(config_path).ok()?;
let reader = io::BufReader::new(file);
for line in reader
.lines()
.map_while(Result::ok)
.take(USER_DIR_CONFIG_LINE_LIMIT)
{
let line = line.trim();
if !line.starts_with(key) {
continue;
}
let (_, value) = line.split_once('=')?;
let value = value.trim().trim_matches('"');
let expanded = value.replace("$HOME", &home_dir().to_string_lossy());
return Some(PathBuf::from(expanded));
}
None
}
#[derive(Clone, Debug)]
pub struct NameValidation {
pub accepted: bool,
pub message: Option<String>,
pub warning: bool,
}
pub fn validate_child_name(
parent: &Path,
name: &str,
target_is_folder: bool,
original_name: Option<&str>,
) -> NameValidation {
let stripped = name.trim();
let noun = if target_is_folder { "folder" } else { "file" };
if stripped.is_empty() {
return NameValidation::error(format!("The {noun} name cannot be empty"));
}
if stripped.contains('/') {
return NameValidation::error(format!("{} names cannot contain /", capitalize(noun)));
}
if stripped.contains('\0') {
return NameValidation::error(format!("{} names cannot contain NUL", capitalize(noun)));
}
if stripped == "." || stripped == ".." {
return NameValidation::error(format!("A {noun} cannot be called . or .."));
}
if stripped.len() > 255 {
return NameValidation::error(format!("The {noun} name is too long"));
}
let same_as_original = original_name.is_some_and(|original| original == stripped);
if !same_as_original && parent.join(stripped).exists() {
let existing_is_dir = parent.join(stripped).is_dir();
let existing = if existing_is_dir { "folder" } else { "file" };
return NameValidation::error(format!("A {existing} with that name already exists"));
}
if stripped.starts_with('.') {
return NameValidation {
accepted: true,
message: Some(format!(
"{}s with . at the beginning of their name are hidden",
capitalize(noun)
)),
warning: true,
};
}
NameValidation {
accepted: true,
message: None,
warning: false,
}
}
impl NameValidation {
fn error(message: String) -> Self {
Self {
accepted: false,
message: Some(message),
warning: false,
}
}
}
pub fn create_folder(parent: &Path, name: &str) -> Result<PathBuf, String> {
let validation = validate_child_name(parent, name, true, None);
if !validation.accepted {
return Err(validation
.message
.unwrap_or_else(|| "Invalid folder name".to_string()));
}
let path = parent.join(name.trim());
fs::create_dir(&path).map_err(|error| format!("Cannot create folder: {error}"))?;
Ok(path)
}
pub fn create_file(parent: &Path, name: &str) -> Result<PathBuf, String> {
let validation = validate_child_name(parent, name, false, None);
if !validation.accepted {
return Err(validation
.message
.unwrap_or_else(|| "Invalid file name".to_string()));
}
let path = parent.join(name.trim());
OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.map_err(|error| format!("Cannot create file: {error}"))?;
Ok(path)
}
pub fn rename_path(path: &Path, new_name: &str) -> Result<PathBuf, String> {
let parent = path
.parent()
.ok_or_else(|| "Cannot rename a path without a parent".to_string())?;
let original_name = path.file_name().and_then(OsStr::to_str);
let validation = validate_child_name(parent, new_name, path.is_dir(), original_name);
if !validation.accepted {
return Err(validation
.message
.unwrap_or_else(|| "Invalid file name".to_string()));
}
let target = parent.join(new_name.trim());
if target == path {
return Ok(target);
}
fs::rename(path, &target).map_err(|error| format!("Cannot rename item: {error}"))?;
Ok(target)
}
pub fn human_size(size: u64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
let mut value = size as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{size} {}", UNITS[unit])
} else if value >= 100.0 {
format!("{value:.0} {}", UNITS[unit])
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
pub fn modified_label(time: Option<SystemTime>) -> String {
let Some(time) = time else {
return "unknown".to_string();
};
let Ok(age) = SystemTime::now().duration_since(time) else {
return "future".to_string();
};
relative_duration(age)
}
pub fn absolute_time_label(time: Option<SystemTime>) -> String {
let Some(time) = time else {
return "unknown".to_string();
};
let Ok(duration) = time.duration_since(UNIX_EPOCH) else {
return "before unix epoch".to_string();
};
unix_timestamp_label(duration.as_secs())
}
pub fn path_for_display(path: &Path) -> String {
let home = home_dir();
if let Ok(stripped) = path.strip_prefix(&home) {
if stripped.as_os_str().is_empty() {
return "~".to_string();
}
return format!("~/{}", stripped.display());
}
path.display().to_string()
}
pub fn breadcrumbs(path: &Path) -> Vec<(String, PathBuf)> {
let mut parts = Vec::new();
let mut current = PathBuf::new();
for component in path.components() {
match component {
Component::RootDir => {
current.push("/");
parts.push(("/".to_string(), current.clone()));
}
Component::Normal(name) => {
current.push(name);
parts.push((name.to_string_lossy().into_owned(), current.clone()));
}
Component::Prefix(prefix) => {
current.push(prefix.as_os_str());
parts.push((
prefix.as_os_str().to_string_lossy().into_owned(),
current.clone(),
));
}
Component::CurDir | Component::ParentDir => {}
}
}
parts
}
pub fn item_glyph(entry: &FileEntry) -> &'static str {
match entry.kind {
EntryKind::Directory => "[DIR]",
EntryKind::Symlink => "[LNK]",
EntryKind::Other => "[SYS]",
EntryKind::File => match entry.extension().as_str() {
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" => "[IMG]",
"mp3" | "flac" | "wav" | "ogg" | "m4a" => "[AUD]",
"mp4" | "mkv" | "mov" | "webm" | "avi" => "[VID]",
"rs" | "c" | "h" | "cpp" | "hpp" | "py" | "js" | "ts" | "tsx" | "jsx" | "go"
| "java" | "kt" | "swift" => "[SRC]",
"md" | "txt" | "rst" | "log" => "[TXT]",
"pdf" => "[PDF]",
"zip" | "tar" | "gz" | "xz" | "zst" | "7z" | "rar" => "[ARC]",
_ => "[FIL]",
},
}
}
fn compare_names(left: &str, right: &str) -> Ordering {
left.to_ascii_lowercase()
.cmp(&right.to_ascii_lowercase())
.then_with(|| left.cmp(right))
}
fn capitalize(value: &str) -> String {
let mut chars = value.chars();
match chars.next() {
Some(first) => first.to_uppercase().chain(chars).collect(),
None => String::new(),
}
}
fn relative_duration(duration: Duration) -> String {
let seconds = duration.as_secs();
match seconds {
0..=59 => "just now".to_string(),
60..=3_599 => format!("{}m ago", seconds / 60),
3_600..=86_399 => format!("{}h ago", seconds / 3_600),
86_400..=2_592_000 => format!("{}d ago", seconds / 86_400),
2_592_001..=31_536_000 => format!("{}mo ago", seconds / 2_592_000),
_ => format!("{}y ago", seconds / 31_536_000),
}
}
fn unix_timestamp_label(seconds: u64) -> String {
let days = (seconds / 86_400) as i64;
let seconds_of_day = seconds % 86_400;
let (year, month, day) = civil_from_days(days);
let hour = seconds_of_day / 3_600;
let minute = (seconds_of_day % 3_600) / 60;
format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02} UTC")
}
fn civil_from_days(days_since_epoch: i64) -> (i64, u32, u32) {
let z = days_since_epoch + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = mp + if mp < 10 { 3 } else { -9 };
let year = y + if month <= 2 { 1 } else { 0 };
(year, month as u32, day as u32)
}
#[cfg(unix)]
fn permission_label(metadata: &Metadata) -> String {
use std::os::unix::fs::PermissionsExt;
let mode = metadata.permissions().mode();
let file_type = if metadata.file_type().is_dir() {
'd'
} else if metadata.file_type().is_symlink() {
'l'
} else {
'-'
};
let flags = [
(0o400, 'r'),
(0o200, 'w'),
(0o100, 'x'),
(0o040, 'r'),
(0o020, 'w'),
(0o010, 'x'),
(0o004, 'r'),
(0o002, 'w'),
(0o001, 'x'),
];
let mut label = String::with_capacity(10);
label.push(file_type);
for (bit, chr) in flags {
label.push(if mode & bit != 0 { chr } else { '-' });
}
label
}
#[cfg(not(unix))]
fn permission_label(metadata: &Metadata) -> String {
if metadata.permissions().readonly() {
"read-only".to_string()
} else {
"read-write".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestDir(PathBuf);
impl TestDir {
fn new(label: &str) -> Self {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or_default();
let path = env::temp_dir().join(format!("guth-{label}-{}-{nonce}", std::process::id()));
fs::create_dir_all(&path).unwrap();
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn validates_nautilus_style_names() {
let root = env::temp_dir();
assert!(!validate_child_name(&root, "", true, None).accepted);
assert!(!validate_child_name(&root, ".", true, None).accepted);
assert!(!validate_child_name(&root, "a/b", false, None).accepted);
let hidden = validate_child_name(&root, ".secret", false, None);
assert!(hidden.accepted);
assert!(hidden.warning);
}
#[test]
fn formats_epoch_calendar_day() {
assert_eq!(unix_timestamp_label(0), "1970-01-01 00:00 UTC");
assert_eq!(unix_timestamp_label(86_400), "1970-01-02 00:00 UTC");
}
#[test]
fn scan_directory_reports_entry_limit() {
let root = TestDir::new("scan-limit");
for index in 0..4 {
fs::write(root.path().join(format!("file-{index}.txt")), b"x").unwrap();
}
let snapshot = scan_directory(
root.path(),
ScanOptions {
show_hidden: true,
sort_key: SortKey::Name,
reverse: false,
folders_first: true,
entry_limit: 2,
error_limit: 4,
},
)
.unwrap();
assert!(snapshot.scan_limited);
assert_eq!(snapshot.total_entries, 2);
assert_eq!(snapshot.entry_limit, 2);
assert!(snapshot.entries.len() <= 2);
}
#[test]
fn recursive_search_reports_result_limit() {
let root = TestDir::new("search-result-limit");
fs::write(root.path().join("alpha-one.txt"), b"x").unwrap();
fs::write(root.path().join("alpha-two.txt"), b"x").unwrap();
let results = recursive_search(
root.path(),
"alpha",
RecursiveSearchOptions {
show_hidden: true,
sort_key: SortKey::Name,
reverse: false,
folders_first: true,
result_limit: 1,
dir_limit: 8,
queue_limit: 8,
},
);
assert_eq!(results.entries.len(), 1);
assert!(results.result_limited);
assert!(results.limited());
}
#[test]
fn recursive_search_can_cancel_before_traversal() {
let results = recursive_search_cancellable(
Path::new("/tmp"),
"anything",
RecursiveSearchOptions {
show_hidden: true,
sort_key: SortKey::Name,
reverse: false,
folders_first: true,
result_limit: 8,
dir_limit: 8,
queue_limit: 8,
},
|| true,
);
assert!(results.entries.is_empty());
assert_eq!(results.visited_dirs, 0);
}
#[test]
fn recursive_search_reports_traversal_limits() {
let root = TestDir::new("search-traversal-limit");
fs::create_dir(root.path().join("child-a")).unwrap();
fs::create_dir(root.path().join("child-b")).unwrap();
let dir_limited = recursive_search(
root.path(),
"child",
RecursiveSearchOptions {
show_hidden: true,
sort_key: SortKey::Name,
reverse: false,
folders_first: true,
result_limit: 8,
dir_limit: 1,
queue_limit: 8,
},
);
let queue_limited = recursive_search(
root.path(),
"child",
RecursiveSearchOptions {
show_hidden: true,
sort_key: SortKey::Name,
reverse: false,
folders_first: true,
result_limit: 8,
dir_limit: 8,
queue_limit: 1,
},
);
assert!(dir_limited.dir_limited);
assert!(queue_limited.queue_limited);
}
#[test]
fn sort_entries_can_disable_folders_first() {
let root = TestDir::new("folders-first");
fs::create_dir(root.path().join("z-folder")).unwrap();
fs::write(root.path().join("a-file.txt"), b"x").unwrap();
let mut entries = vec![
FileEntry::from_path(root.path().join("z-folder")).unwrap(),
FileEntry::from_path(root.path().join("a-file.txt")).unwrap(),
];
sort_entries(&mut entries, SortKey::Name, false, true);
assert_eq!(entries[0].name, "z-folder");
sort_entries(&mut entries, SortKey::Name, false, false);
assert_eq!(entries[0].name, "a-file.txt");
}
}