use std::{
collections::BTreeSet,
fmt,
fs::{self, File, Metadata},
io::{self, Read},
path::{Component, Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use mime_guess::MimeGuess;
use time::{OffsetDateTime, macros::format_description};
use crate::StorageUsage;
use crate::model::{Breadcrumb, DirectoryListing, FileEntry, FileKind, SearchResults};
const MODIFIED_FORMAT: &[time::format_description::FormatItem<'static>] =
format_description!("[year]-[month]-[day] [hour]:[minute]");
const DEFAULT_SEARCH_LIMIT: usize = 200;
#[derive(Debug)]
pub enum FileServiceError {
InvalidRoot(PathBuf),
InvalidPath(String),
AlreadyExists(String),
NotFound(String),
NotADirectory(String),
NotAFile(String),
OutsideRoot(String),
Io(io::Error),
}
impl FileServiceError {
pub fn invalid_path(path: impl Into<String>) -> Self {
Self::InvalidPath(path.into())
}
}
impl fmt::Display for FileServiceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidRoot(path) => write!(
f,
"Configured root '{}' does not exist or is not a directory.",
path.display()
),
Self::InvalidPath(path) => {
write!(f, "Path '{path}' is invalid. Relative child paths only.")
}
Self::AlreadyExists(path) => write!(f, "Path '{path}' already exists."),
Self::NotFound(path) => write!(f, "Path '{path}' was not found."),
Self::NotADirectory(path) => write!(f, "Path '{path}' is not a directory."),
Self::NotAFile(path) => write!(f, "Path '{path}' is not a file."),
Self::OutsideRoot(path) => write!(
f,
"Path '{path}' resolves outside the configured root directory."
),
Self::Io(error) => error.fmt(f),
}
}
}
impl std::error::Error for FileServiceError {}
impl From<io::Error> for FileServiceError {
fn from(value: io::Error) -> Self {
Self::Io(value)
}
}
#[derive(Clone, Debug)]
pub struct FileService {
root_dir: PathBuf,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileAsset {
pub relative_path: String,
pub absolute_path: PathBuf,
pub file_name: String,
pub extension: Option<String>,
pub mime_type: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DeleteSummary {
pub deleted_count: usize,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MoveSummary {
pub moved_count: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum MoveOperationKind {
Directory,
File,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct MoveOperation {
relative_path: String,
source_path: PathBuf,
kind: MoveOperationKind,
}
impl FileService {
pub fn new(root_dir: impl Into<PathBuf>) -> Result<Self, FileServiceError> {
let configured_root = root_dir.into();
let canonical_root = configured_root
.canonicalize()
.map_err(|_| FileServiceError::InvalidRoot(configured_root.clone()))?;
if !canonical_root.is_dir() {
return Err(FileServiceError::InvalidRoot(canonical_root));
}
Ok(Self {
root_dir: canonical_root,
})
}
pub fn root_dir(&self) -> &Path {
&self.root_dir
}
pub fn list_dir(&self, requested_path: &str) -> Result<DirectoryListing, FileServiceError> {
let relative_path = sanitize_relative_path(requested_path)?;
let resolved_path = self.resolve_existing_path(&relative_path)?;
if !resolved_path.is_dir() {
return Err(FileServiceError::NotADirectory(display_relative_path(
&relative_path,
)));
}
let mut entries = fs::read_dir(&resolved_path)?
.map(|entry| {
let entry = entry?;
let entry_relative_path = join_relative_path(&relative_path, &entry.file_name());
self.build_entry(&entry.path(), &entry_relative_path)
})
.collect::<Result<Vec<_>, _>>()?;
sort_entries(&mut entries);
Ok(DirectoryListing {
current_path: relative_path.clone(),
current_path_display: display_relative_path(&relative_path),
breadcrumbs: build_breadcrumbs(&relative_path),
total_entries: entries.len(),
entries,
})
}
pub fn search(&self, query: &str) -> Result<SearchResults, FileServiceError> {
self.search_with_limit(query, DEFAULT_SEARCH_LIMIT)
}
pub fn entry_kind(&self, requested_path: &str) -> Result<FileKind, FileServiceError> {
let relative_path = sanitize_relative_path(requested_path)?;
if relative_path.is_empty() {
return Err(FileServiceError::InvalidPath("/".to_string()));
}
let resolved_path = self.resolve_existing_path(&relative_path)?;
let metadata = fs::symlink_metadata(&resolved_path)?;
Ok(classify_kind(&metadata))
}
pub fn storage_usage(&self) -> Result<StorageUsage, FileServiceError> {
let total_bytes = fs2::total_space(&self.root_dir)?;
let available_bytes = fs2::available_space(&self.root_dir)?;
Ok(storage_usage_from_totals(total_bytes, available_bytes))
}
pub fn delete_entries(
&self,
requested_paths: &[String],
) -> Result<DeleteSummary, FileServiceError> {
let mut retained_paths = normalize_requested_paths(requested_paths)?;
retained_paths.sort_by_key(|path| std::cmp::Reverse(path.matches('/').count()));
for relative_path in &retained_paths {
let resolved_path = self.resolve_existing_path(relative_path)?;
if resolved_path.is_dir() {
fs::remove_dir_all(&resolved_path)?;
} else {
fs::remove_file(&resolved_path)?;
}
}
Ok(DeleteSummary {
deleted_count: retained_paths.len(),
})
}
pub fn move_operation_count_to(
&self,
requested_paths: &[String],
target: &FileService,
) -> Result<usize, FileServiceError> {
let normalized_paths = normalize_requested_paths(requested_paths)?;
self.ensure_move_destinations_available(&normalized_paths, target)?;
Ok(self.collect_move_operations(&normalized_paths)?.len())
}
pub fn move_entries_to(
&self,
requested_paths: &[String],
target: &FileService,
mut on_progress: impl FnMut(usize, usize, &str),
) -> Result<MoveSummary, FileServiceError> {
let normalized_paths = normalize_requested_paths(requested_paths)?;
self.ensure_move_destinations_available(&normalized_paths, target)?;
let operations = self.collect_move_operations(&normalized_paths)?;
let total_operations = operations.len();
for (index, operation) in operations.iter().enumerate() {
let destination_path = target.resolve_destination_path(&operation.relative_path)?;
match operation.kind {
MoveOperationKind::Directory => {
fs::create_dir_all(&destination_path)?;
}
MoveOperationKind::File => {
if let Some(parent) = destination_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&operation.source_path, &destination_path)?;
fs::remove_file(&operation.source_path)?;
}
}
on_progress(index + 1, total_operations, &operation.relative_path);
}
self.cleanup_move_sources(&normalized_paths)?;
Ok(MoveSummary {
moved_count: total_operations,
})
}
pub fn collect_download_assets(
&self,
requested_paths: &[String],
) -> Result<Vec<FileAsset>, FileServiceError> {
let normalized_paths = normalize_requested_paths(requested_paths)?;
let mut assets = Vec::new();
let mut visited_directories = BTreeSet::new();
for relative_path in normalized_paths {
self.collect_download_assets_from(
&relative_path,
&mut assets,
&mut visited_directories,
)?;
}
assets.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
assets.dedup_by(|left, right| left.relative_path == right.relative_path);
Ok(assets)
}
pub fn file_asset(&self, requested_path: &str) -> Result<FileAsset, FileServiceError> {
let relative_path = sanitize_relative_path(requested_path)?;
if relative_path.is_empty() {
return Err(FileServiceError::NotAFile("/".to_string()));
}
let resolved_path = self.resolve_existing_path(&relative_path)?;
if !resolved_path.is_file() {
return Err(FileServiceError::NotAFile(display_relative_path(
&relative_path,
)));
}
Ok(FileAsset {
file_name: resolved_path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| relative_path.clone()),
extension: resolved_path
.extension()
.map(|extension| extension.to_string_lossy().to_lowercase()),
mime_type: MimeGuess::from_path(&resolved_path)
.first_or_octet_stream()
.essence_str()
.to_string(),
absolute_path: resolved_path,
relative_path,
})
}
pub fn read_text_excerpt(
&self,
asset: &FileAsset,
max_bytes: usize,
) -> Result<String, FileServiceError> {
let file = File::open(&asset.absolute_path)?;
let mut buffer = Vec::with_capacity(max_bytes.min(32 * 1024));
file.take(max_bytes as u64).read_to_end(&mut buffer)?;
Ok(String::from_utf8_lossy(&buffer).into_owned())
}
pub fn search_with_limit(
&self,
query: &str,
limit: usize,
) -> Result<SearchResults, FileServiceError> {
let trimmed_query = query.trim();
if trimmed_query.is_empty() {
return Ok(SearchResults::default());
}
let normalized_query = trimmed_query.to_lowercase();
let mut matches = Vec::new();
let mut total_matches = 0;
self.walk_search(
self.root_dir(),
"",
&normalized_query,
limit.max(1),
&mut matches,
&mut total_matches,
)?;
sort_entries(&mut matches);
Ok(SearchResults {
query: trimmed_query.to_string(),
entries: matches,
total_matches,
is_truncated: total_matches > limit.max(1),
})
}
fn walk_search(
&self,
directory: &Path,
relative_path: &str,
query: &str,
limit: usize,
matches: &mut Vec<FileEntry>,
total_matches: &mut usize,
) -> Result<(), FileServiceError> {
for entry in fs::read_dir(directory)? {
let entry = entry?;
let file_name = entry.file_name();
let entry_relative_path = join_relative_path(relative_path, &file_name);
let file_type = entry.file_type()?;
let file_name_label = file_name.to_string_lossy().to_string();
if file_name_label.to_lowercase().contains(query) {
*total_matches += 1;
if matches.len() < limit {
matches.push(self.build_entry(&entry.path(), &entry_relative_path)?);
}
}
if file_type.is_dir() && !file_type.is_symlink() {
self.walk_search(
&entry.path(),
&entry_relative_path,
query,
limit,
matches,
total_matches,
)?;
}
}
Ok(())
}
fn build_entry(
&self,
full_path: &Path,
relative_path: &str,
) -> Result<FileEntry, FileServiceError> {
let metadata = fs::symlink_metadata(full_path)?;
let file_kind = classify_kind(&metadata);
let name = full_path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| "/".to_string());
Ok(FileEntry {
name,
relative_path: relative_path.to_string(),
parent_relative_path: parent_relative_path(relative_path),
is_directory: matches!(file_kind, FileKind::Directory),
in_batch: false,
kind: file_kind,
size_bytes: metadata.len(),
modified_unix_seconds: metadata
.modified()
.ok()
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
.map(|value| value.as_secs()),
size_label: size_label(&metadata, file_kind),
modified_label: modified_label(metadata.modified().ok()),
permissions_label: permissions_label(&metadata),
})
}
fn collect_download_assets_from(
&self,
relative_path: &str,
assets: &mut Vec<FileAsset>,
visited_directories: &mut BTreeSet<PathBuf>,
) -> Result<(), FileServiceError> {
let relative_path = sanitize_relative_path(relative_path)?;
if relative_path.is_empty() {
return Ok(());
}
let resolved_path = self.resolve_existing_path(&relative_path)?;
let metadata = fs::metadata(&resolved_path)?;
if metadata.is_dir() {
if !visited_directories.insert(resolved_path.clone()) {
return Ok(());
}
for entry in fs::read_dir(&resolved_path)? {
let entry = entry?;
let child_relative_path = join_relative_path(&relative_path, &entry.file_name());
self.collect_download_assets_from(
&child_relative_path,
assets,
visited_directories,
)?;
}
return Ok(());
}
if metadata.is_file() {
assets.push(self.file_asset(&relative_path)?);
return Ok(());
}
Err(FileServiceError::NotAFile(display_relative_path(
&relative_path,
)))
}
fn collect_move_operations(
&self,
requested_paths: &[String],
) -> Result<Vec<MoveOperation>, FileServiceError> {
let mut operations = Vec::new();
for relative_path in requested_paths {
self.collect_move_operations_from(relative_path, &mut operations)?;
}
Ok(operations)
}
fn collect_move_operations_from(
&self,
relative_path: &str,
operations: &mut Vec<MoveOperation>,
) -> Result<(), FileServiceError> {
let resolved_path = self.resolve_existing_path(relative_path)?;
let metadata = fs::symlink_metadata(&resolved_path)?;
if metadata.is_dir() {
operations.push(MoveOperation {
relative_path: relative_path.to_string(),
source_path: resolved_path.clone(),
kind: MoveOperationKind::Directory,
});
let mut child_paths = fs::read_dir(&resolved_path)?
.map(|entry| {
let entry = entry?;
Ok(join_relative_path(relative_path, &entry.file_name()))
})
.collect::<Result<Vec<_>, io::Error>>()?;
child_paths.sort();
for child_relative_path in child_paths {
self.collect_move_operations_from(&child_relative_path, operations)?;
}
return Ok(());
}
if metadata.is_file() {
operations.push(MoveOperation {
relative_path: relative_path.to_string(),
source_path: resolved_path,
kind: MoveOperationKind::File,
});
return Ok(());
}
Err(FileServiceError::NotAFile(display_relative_path(
relative_path,
)))
}
fn resolve_existing_path(&self, relative_path: &str) -> Result<PathBuf, FileServiceError> {
let joined = if relative_path.is_empty() {
self.root_dir.clone()
} else {
self.root_dir.join(relative_path)
};
let canonical_path = joined.canonicalize().map_err(|error| match error.kind() {
io::ErrorKind::NotFound => {
FileServiceError::NotFound(display_relative_path(relative_path))
}
_ => FileServiceError::Io(error),
})?;
if !canonical_path.starts_with(&self.root_dir) {
return Err(FileServiceError::OutsideRoot(display_relative_path(
relative_path,
)));
}
Ok(canonical_path)
}
fn resolve_destination_path(&self, relative_path: &str) -> Result<PathBuf, FileServiceError> {
let sanitized = sanitize_relative_path(relative_path)?;
Ok(if sanitized.is_empty() {
self.root_dir.clone()
} else {
self.root_dir.join(sanitized)
})
}
fn ensure_move_destinations_available(
&self,
requested_paths: &[String],
target: &FileService,
) -> Result<(), FileServiceError> {
if self.root_dir == target.root_dir {
return Err(FileServiceError::invalid_path(
"Select a different destination mount.",
));
}
for relative_path in requested_paths {
let destination_path = target.resolve_destination_path(relative_path)?;
if destination_path.exists() {
return Err(FileServiceError::AlreadyExists(display_relative_path(
relative_path,
)));
}
}
Ok(())
}
fn cleanup_move_sources(&self, requested_paths: &[String]) -> Result<(), FileServiceError> {
let mut cleanup_paths = requested_paths.to_vec();
cleanup_paths.sort_by_key(|path| std::cmp::Reverse(path.matches('/').count()));
for relative_path in cleanup_paths {
let source_path = self.resolve_destination_path(&relative_path)?;
if !source_path.exists() {
continue;
}
if source_path.is_dir() {
fs::remove_dir_all(&source_path)?;
} else {
fs::remove_file(&source_path)?;
}
}
Ok(())
}
}
fn sanitize_relative_path(input: &str) -> Result<String, FileServiceError> {
let trimmed = input.trim().trim_matches('/');
if trimmed.is_empty() {
return Ok(String::new());
}
let mut parts = Vec::new();
for component in Path::new(trimmed).components() {
match component {
Component::CurDir => {}
Component::Normal(part) => parts.push(part.to_string_lossy().to_string()),
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(FileServiceError::invalid_path(input));
}
}
}
Ok(parts.join("/"))
}
fn build_breadcrumbs(relative_path: &str) -> Vec<Breadcrumb> {
let mut breadcrumbs = vec![Breadcrumb {
name: "Root".to_string(),
path: String::new(),
}];
if relative_path.is_empty() {
return breadcrumbs;
}
let mut current = String::new();
for segment in relative_path.split('/') {
if !current.is_empty() {
current.push('/');
}
current.push_str(segment);
breadcrumbs.push(Breadcrumb {
name: segment.to_string(),
path: current.clone(),
});
}
breadcrumbs
}
fn join_relative_path(base: &str, file_name: &std::ffi::OsStr) -> String {
let name = file_name.to_string_lossy();
if base.is_empty() {
name.to_string()
} else {
format!("{base}/{name}")
}
}
fn parent_relative_path(relative_path: &str) -> String {
match relative_path.rsplit_once('/') {
Some((parent, _)) => parent.to_string(),
None => String::new(),
}
}
fn display_relative_path(relative_path: &str) -> String {
if relative_path.is_empty() {
"/".to_string()
} else {
format!("/{relative_path}")
}
}
fn normalize_requested_paths(requested_paths: &[String]) -> Result<Vec<String>, FileServiceError> {
let mut normalized_paths = requested_paths
.iter()
.map(|path| sanitize_relative_path(path))
.collect::<Result<Vec<_>, _>>()?;
normalized_paths.retain(|path| !path.is_empty());
normalized_paths.sort();
normalized_paths.dedup();
let mut retained_paths = Vec::new();
for path in normalized_paths {
let already_covered = retained_paths.iter().any(|ancestor: &String| {
path == *ancestor
|| path
.strip_prefix(ancestor)
.is_some_and(|suffix| suffix.starts_with('/'))
});
if !already_covered {
retained_paths.push(path);
}
}
Ok(retained_paths)
}
fn storage_usage_from_totals(total_bytes: u64, available_bytes: u64) -> StorageUsage {
let available_bytes = available_bytes.min(total_bytes);
let used_bytes = total_bytes.saturating_sub(available_bytes);
let used_percent = if total_bytes == 0 {
0
} else {
((used_bytes.saturating_mul(100)) / total_bytes) as u8
};
StorageUsage {
used_bytes,
available_bytes,
total_bytes,
used_percent,
used_label: format_byte_size(used_bytes),
available_label: format_byte_size(available_bytes),
total_label: format_byte_size(total_bytes),
}
}
fn classify_kind(metadata: &Metadata) -> FileKind {
let file_type = metadata.file_type();
if file_type.is_dir() {
FileKind::Directory
} else if file_type.is_file() {
FileKind::File
} else if file_type.is_symlink() {
FileKind::Symlink
} else {
FileKind::Other
}
}
fn size_label(metadata: &Metadata, kind: FileKind) -> String {
if kind == FileKind::Directory {
return "Folder".to_string();
}
format_byte_size(metadata.len())
}
fn format_byte_size(bytes: u64) -> String {
let size = bytes as f64;
let units = ["B", "KB", "MB", "GB", "TB"];
let mut unit = 0;
let mut value = size;
while value >= 1024.0 && unit < units.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} {}", units[unit])
} else {
format!("{value:.1} {}", units[unit])
}
}
fn modified_label(modified_time: Option<SystemTime>) -> String {
modified_time
.map(OffsetDateTime::from)
.and_then(|time| time.format(MODIFIED_FORMAT).ok())
.unwrap_or_else(|| "Unknown".to_string())
}
#[cfg(unix)]
fn permissions_label(metadata: &Metadata) -> String {
use std::os::unix::fs::PermissionsExt;
format!("{:03o}", metadata.permissions().mode() & 0o777)
}
#[cfg(not(unix))]
fn permissions_label(metadata: &Metadata) -> String {
if metadata.permissions().readonly() {
"readonly".to_string()
} else {
"read/write".to_string()
}
}
fn sort_entries(entries: &mut [FileEntry]) {
entries.sort_by(|left, right| {
right
.is_directory
.cmp(&left.is_directory)
.then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
});
}
#[cfg(test)]
mod tests {
use std::{fs, path::Path};
use tempfile::tempdir;
use super::FileService;
#[test]
fn lists_directories_before_files() {
let temp_dir = tempdir().unwrap();
fs::create_dir(temp_dir.path().join("folder")).unwrap();
fs::write(temp_dir.path().join("b.txt"), "world").unwrap();
fs::write(temp_dir.path().join("a.txt"), "hello").unwrap();
let service = FileService::new(temp_dir.path()).unwrap();
let listing = service.list_dir("").unwrap();
assert_eq!(listing.current_path_display, "/");
assert_eq!(listing.breadcrumbs[0].name, "Root");
assert_eq!(listing.entries[0].name, "folder");
assert!(listing.entries[0].is_directory);
assert_eq!(listing.entries[1].name, "a.txt");
assert_eq!(listing.entries[2].name, "b.txt");
}
#[test]
fn rejects_parent_directory_traversal() {
let temp_dir = tempdir().unwrap();
let service = FileService::new(temp_dir.path()).unwrap();
let error = service.list_dir("../etc").unwrap_err();
assert_eq!(
error.to_string(),
"Path '../etc' is invalid. Relative child paths only."
);
}
#[test]
fn search_flattens_nested_matches() {
let temp_dir = tempdir().unwrap();
fs::create_dir_all(temp_dir.path().join(Path::new("images/nested"))).unwrap();
fs::write(temp_dir.path().join("images/logo.png"), "png").unwrap();
fs::write(temp_dir.path().join("images/nested/hero.png"), "png").unwrap();
fs::write(temp_dir.path().join("images/readme.txt"), "text").unwrap();
let service = FileService::new(temp_dir.path()).unwrap();
let results = service.search("png").unwrap();
assert_eq!(results.total_matches, 2);
assert_eq!(results.entries.len(), 2);
assert!(
results
.entries
.iter()
.all(|entry| entry.relative_path.ends_with(".png"))
);
}
#[test]
fn resolves_file_assets_with_mime() {
let temp_dir = tempdir().unwrap();
fs::write(temp_dir.path().join("notes.txt"), "hello").unwrap();
let service = FileService::new(temp_dir.path()).unwrap();
let asset = service.file_asset("notes.txt").unwrap();
assert_eq!(asset.file_name, "notes.txt");
assert_eq!(asset.extension.as_deref(), Some("txt"));
assert_eq!(asset.mime_type, "text/plain");
}
#[test]
fn deletes_files_and_directories_once() {
let temp_dir = tempdir().unwrap();
fs::create_dir_all(temp_dir.path().join("nested/child")).unwrap();
fs::write(temp_dir.path().join("nested/child/file.txt"), "hello").unwrap();
fs::write(temp_dir.path().join("root.txt"), "hello").unwrap();
let service = FileService::new(temp_dir.path()).unwrap();
let summary = service
.delete_entries(&[
"nested".to_string(),
"nested/child/file.txt".to_string(),
"root.txt".to_string(),
])
.unwrap();
assert_eq!(summary.deleted_count, 2);
assert!(!temp_dir.path().join("nested").exists());
assert!(!temp_dir.path().join("root.txt").exists());
}
#[test]
fn storage_usage_formats_labels_and_percent() {
let usage = super::storage_usage_from_totals(100, 35);
assert_eq!(usage.used_bytes, 65);
assert_eq!(usage.available_bytes, 35);
assert_eq!(usage.used_percent, 65);
assert_eq!(usage.used_label, "65 B");
assert_eq!(usage.available_label, "35 B");
assert_eq!(usage.total_label, "100 B");
}
#[test]
fn collects_download_assets_from_files_and_directories_once() {
let temp_dir = tempdir().unwrap();
fs::create_dir_all(temp_dir.path().join("nested/child")).unwrap();
fs::write(temp_dir.path().join("nested/child/one.txt"), "one").unwrap();
fs::write(temp_dir.path().join("nested/two.txt"), "two").unwrap();
fs::write(temp_dir.path().join("root.txt"), "root").unwrap();
let service = FileService::new(temp_dir.path()).unwrap();
let assets = service
.collect_download_assets(&[
"nested".to_string(),
"nested/child/one.txt".to_string(),
"root.txt".to_string(),
])
.unwrap();
let relative_paths = assets
.iter()
.map(|asset| asset.relative_path.as_str())
.collect::<Vec<_>>();
assert_eq!(
relative_paths,
vec!["nested/child/one.txt", "nested/two.txt", "root.txt"]
);
}
#[test]
fn moves_file_to_other_root_preserving_relative_path() {
let source_dir = tempdir().unwrap();
let target_dir = tempdir().unwrap();
fs::create_dir_all(source_dir.path().join("lib/src")).unwrap();
fs::write(source_dir.path().join("lib/src/main.rs"), "fn main() {}\n").unwrap();
let source = FileService::new(source_dir.path()).unwrap();
let target = FileService::new(target_dir.path()).unwrap();
let summary = source
.move_entries_to(&["lib/src/main.rs".to_string()], &target, |_, _, _| {})
.unwrap();
assert_eq!(summary.moved_count, 1);
assert!(!source_dir.path().join("lib/src/main.rs").exists());
assert_eq!(
fs::read_to_string(target_dir.path().join("lib/src/main.rs")).unwrap(),
"fn main() {}\n"
);
}
#[test]
fn moves_directories_including_empty_children() {
let source_dir = tempdir().unwrap();
let target_dir = tempdir().unwrap();
fs::create_dir_all(source_dir.path().join("nested/child/empty")).unwrap();
fs::write(source_dir.path().join("nested/child/file.txt"), "hello").unwrap();
let source = FileService::new(source_dir.path()).unwrap();
let target = FileService::new(target_dir.path()).unwrap();
source
.move_entries_to(&["nested".to_string()], &target, |_, _, _| {})
.unwrap();
assert!(!source_dir.path().join("nested").exists());
assert_eq!(
fs::read_to_string(target_dir.path().join("nested/child/file.txt")).unwrap(),
"hello"
);
assert!(target_dir.path().join("nested/child/empty").is_dir());
}
}