1use std::{
2 collections::BTreeSet,
3 fmt,
4 fs::{self, File, Metadata},
5 io::{self, Read},
6 path::{Component, Path, PathBuf},
7 time::{SystemTime, UNIX_EPOCH},
8};
9
10use mime_guess::MimeGuess;
11use time::{OffsetDateTime, macros::format_description};
12
13use crate::StorageUsage;
14use crate::model::{Breadcrumb, DirectoryListing, FileEntry, FileKind, SearchResults};
15
16const MODIFIED_FORMAT: &[time::format_description::FormatItem<'static>] =
17 format_description!("[year]-[month]-[day] [hour]:[minute]");
18const DEFAULT_SEARCH_LIMIT: usize = 200;
19
20#[derive(Debug)]
21pub enum FileServiceError {
22 InvalidRoot(PathBuf),
23 InvalidPath(String),
24 AlreadyExists(String),
25 NotFound(String),
26 NotADirectory(String),
27 NotAFile(String),
28 OutsideRoot(String),
29 Io(io::Error),
30}
31
32impl FileServiceError {
33 pub fn invalid_path(path: impl Into<String>) -> Self {
34 Self::InvalidPath(path.into())
35 }
36}
37
38impl fmt::Display for FileServiceError {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 match self {
41 Self::InvalidRoot(path) => write!(
42 f,
43 "Configured root '{}' does not exist or is not a directory.",
44 path.display()
45 ),
46 Self::InvalidPath(path) => {
47 write!(f, "Path '{path}' is invalid. Relative child paths only.")
48 }
49 Self::AlreadyExists(path) => write!(f, "Path '{path}' already exists."),
50 Self::NotFound(path) => write!(f, "Path '{path}' was not found."),
51 Self::NotADirectory(path) => write!(f, "Path '{path}' is not a directory."),
52 Self::NotAFile(path) => write!(f, "Path '{path}' is not a file."),
53 Self::OutsideRoot(path) => write!(
54 f,
55 "Path '{path}' resolves outside the configured root directory."
56 ),
57 Self::Io(error) => error.fmt(f),
58 }
59 }
60}
61
62impl std::error::Error for FileServiceError {}
63
64impl From<io::Error> for FileServiceError {
65 fn from(value: io::Error) -> Self {
66 Self::Io(value)
67 }
68}
69
70#[derive(Clone, Debug)]
71pub struct FileService {
72 root_dir: PathBuf,
73}
74
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct FileAsset {
77 pub relative_path: String,
78 pub absolute_path: PathBuf,
79 pub file_name: String,
80 pub extension: Option<String>,
81 pub mime_type: String,
82}
83
84#[derive(Clone, Debug, Default, PartialEq, Eq)]
85pub struct DeleteSummary {
86 pub deleted_count: usize,
87}
88
89#[derive(Clone, Debug, Default, PartialEq, Eq)]
90pub struct MoveSummary {
91 pub moved_count: usize,
92}
93
94#[derive(Clone, Debug, PartialEq, Eq)]
95enum MoveOperationKind {
96 Directory,
97 File,
98}
99
100#[derive(Clone, Debug, PartialEq, Eq)]
101struct MoveOperation {
102 relative_path: String,
103 source_path: PathBuf,
104 kind: MoveOperationKind,
105}
106
107impl FileService {
108 pub fn new(root_dir: impl Into<PathBuf>) -> Result<Self, FileServiceError> {
109 let configured_root = root_dir.into();
110 let canonical_root = configured_root
111 .canonicalize()
112 .map_err(|_| FileServiceError::InvalidRoot(configured_root.clone()))?;
113
114 if !canonical_root.is_dir() {
115 return Err(FileServiceError::InvalidRoot(canonical_root));
116 }
117
118 Ok(Self {
119 root_dir: canonical_root,
120 })
121 }
122
123 pub fn root_dir(&self) -> &Path {
124 &self.root_dir
125 }
126
127 pub fn list_dir(&self, requested_path: &str) -> Result<DirectoryListing, FileServiceError> {
128 let relative_path = sanitize_relative_path(requested_path)?;
129 let resolved_path = self.resolve_existing_path(&relative_path)?;
130
131 if !resolved_path.is_dir() {
132 return Err(FileServiceError::NotADirectory(display_relative_path(
133 &relative_path,
134 )));
135 }
136
137 let mut entries = fs::read_dir(&resolved_path)?
138 .map(|entry| {
139 let entry = entry?;
140 let entry_relative_path = join_relative_path(&relative_path, &entry.file_name());
141 self.build_entry(&entry.path(), &entry_relative_path)
142 })
143 .collect::<Result<Vec<_>, _>>()?;
144
145 sort_entries(&mut entries);
146
147 Ok(DirectoryListing {
148 current_path: relative_path.clone(),
149 current_path_display: display_relative_path(&relative_path),
150 breadcrumbs: build_breadcrumbs(&relative_path),
151 total_entries: entries.len(),
152 entries,
153 })
154 }
155
156 pub fn search(&self, query: &str) -> Result<SearchResults, FileServiceError> {
157 self.search_with_limit(query, DEFAULT_SEARCH_LIMIT)
158 }
159
160 pub fn entry_kind(&self, requested_path: &str) -> Result<FileKind, FileServiceError> {
161 let relative_path = sanitize_relative_path(requested_path)?;
162
163 if relative_path.is_empty() {
164 return Err(FileServiceError::InvalidPath("/".to_string()));
165 }
166
167 let resolved_path = self.resolve_existing_path(&relative_path)?;
168 let metadata = fs::symlink_metadata(&resolved_path)?;
169 Ok(classify_kind(&metadata))
170 }
171
172 pub fn storage_usage(&self) -> Result<StorageUsage, FileServiceError> {
173 let total_bytes = fs2::total_space(&self.root_dir)?;
174 let available_bytes = fs2::available_space(&self.root_dir)?;
175 Ok(storage_usage_from_totals(total_bytes, available_bytes))
176 }
177
178 pub fn delete_entries(
179 &self,
180 requested_paths: &[String],
181 ) -> Result<DeleteSummary, FileServiceError> {
182 let mut retained_paths = normalize_requested_paths(requested_paths)?;
183
184 retained_paths.sort_by_key(|path| std::cmp::Reverse(path.matches('/').count()));
185
186 for relative_path in &retained_paths {
187 let resolved_path = self.resolve_existing_path(relative_path)?;
188
189 if resolved_path.is_dir() {
190 fs::remove_dir_all(&resolved_path)?;
191 } else {
192 fs::remove_file(&resolved_path)?;
193 }
194 }
195
196 Ok(DeleteSummary {
197 deleted_count: retained_paths.len(),
198 })
199 }
200
201 pub fn move_operation_count_to(
202 &self,
203 requested_paths: &[String],
204 target: &FileService,
205 ) -> Result<usize, FileServiceError> {
206 let normalized_paths = normalize_requested_paths(requested_paths)?;
207 self.ensure_move_destinations_available(&normalized_paths, target)?;
208 Ok(self.collect_move_operations(&normalized_paths)?.len())
209 }
210
211 pub fn move_entries_to(
212 &self,
213 requested_paths: &[String],
214 target: &FileService,
215 mut on_progress: impl FnMut(usize, usize, &str),
216 ) -> Result<MoveSummary, FileServiceError> {
217 let normalized_paths = normalize_requested_paths(requested_paths)?;
218 self.ensure_move_destinations_available(&normalized_paths, target)?;
219 let operations = self.collect_move_operations(&normalized_paths)?;
220 let total_operations = operations.len();
221
222 for (index, operation) in operations.iter().enumerate() {
223 let destination_path = target.resolve_destination_path(&operation.relative_path)?;
224
225 match operation.kind {
226 MoveOperationKind::Directory => {
227 fs::create_dir_all(&destination_path)?;
228 }
229 MoveOperationKind::File => {
230 if let Some(parent) = destination_path.parent() {
231 fs::create_dir_all(parent)?;
232 }
233 fs::copy(&operation.source_path, &destination_path)?;
234 fs::remove_file(&operation.source_path)?;
235 }
236 }
237
238 on_progress(index + 1, total_operations, &operation.relative_path);
239 }
240
241 self.cleanup_move_sources(&normalized_paths)?;
242
243 Ok(MoveSummary {
244 moved_count: total_operations,
245 })
246 }
247
248 pub fn collect_download_assets(
249 &self,
250 requested_paths: &[String],
251 ) -> Result<Vec<FileAsset>, FileServiceError> {
252 let normalized_paths = normalize_requested_paths(requested_paths)?;
253 let mut assets = Vec::new();
254 let mut visited_directories = BTreeSet::new();
255
256 for relative_path in normalized_paths {
257 self.collect_download_assets_from(
258 &relative_path,
259 &mut assets,
260 &mut visited_directories,
261 )?;
262 }
263
264 assets.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
265 assets.dedup_by(|left, right| left.relative_path == right.relative_path);
266 Ok(assets)
267 }
268
269 pub fn file_asset(&self, requested_path: &str) -> Result<FileAsset, FileServiceError> {
270 let relative_path = sanitize_relative_path(requested_path)?;
271
272 if relative_path.is_empty() {
273 return Err(FileServiceError::NotAFile("/".to_string()));
274 }
275
276 let resolved_path = self.resolve_existing_path(&relative_path)?;
277 if !resolved_path.is_file() {
278 return Err(FileServiceError::NotAFile(display_relative_path(
279 &relative_path,
280 )));
281 }
282
283 Ok(FileAsset {
284 file_name: resolved_path
285 .file_name()
286 .map(|name| name.to_string_lossy().to_string())
287 .unwrap_or_else(|| relative_path.clone()),
288 extension: resolved_path
289 .extension()
290 .map(|extension| extension.to_string_lossy().to_lowercase()),
291 mime_type: MimeGuess::from_path(&resolved_path)
292 .first_or_octet_stream()
293 .essence_str()
294 .to_string(),
295 absolute_path: resolved_path,
296 relative_path,
297 })
298 }
299
300 pub fn read_text_excerpt(
301 &self,
302 asset: &FileAsset,
303 max_bytes: usize,
304 ) -> Result<String, FileServiceError> {
305 let file = File::open(&asset.absolute_path)?;
306 let mut buffer = Vec::with_capacity(max_bytes.min(32 * 1024));
307 file.take(max_bytes as u64).read_to_end(&mut buffer)?;
308
309 Ok(String::from_utf8_lossy(&buffer).into_owned())
310 }
311
312 pub fn search_with_limit(
313 &self,
314 query: &str,
315 limit: usize,
316 ) -> Result<SearchResults, FileServiceError> {
317 let trimmed_query = query.trim();
318
319 if trimmed_query.is_empty() {
320 return Ok(SearchResults::default());
321 }
322
323 let normalized_query = trimmed_query.to_lowercase();
324 let mut matches = Vec::new();
325 let mut total_matches = 0;
326
327 self.walk_search(
328 self.root_dir(),
329 "",
330 &normalized_query,
331 limit.max(1),
332 &mut matches,
333 &mut total_matches,
334 )?;
335
336 sort_entries(&mut matches);
337
338 Ok(SearchResults {
339 query: trimmed_query.to_string(),
340 entries: matches,
341 total_matches,
342 is_truncated: total_matches > limit.max(1),
343 })
344 }
345
346 fn walk_search(
347 &self,
348 directory: &Path,
349 relative_path: &str,
350 query: &str,
351 limit: usize,
352 matches: &mut Vec<FileEntry>,
353 total_matches: &mut usize,
354 ) -> Result<(), FileServiceError> {
355 for entry in fs::read_dir(directory)? {
356 let entry = entry?;
357 let file_name = entry.file_name();
358 let entry_relative_path = join_relative_path(relative_path, &file_name);
359 let file_type = entry.file_type()?;
360 let file_name_label = file_name.to_string_lossy().to_string();
361
362 if file_name_label.to_lowercase().contains(query) {
363 *total_matches += 1;
364 if matches.len() < limit {
365 matches.push(self.build_entry(&entry.path(), &entry_relative_path)?);
366 }
367 }
368
369 if file_type.is_dir() && !file_type.is_symlink() {
370 self.walk_search(
371 &entry.path(),
372 &entry_relative_path,
373 query,
374 limit,
375 matches,
376 total_matches,
377 )?;
378 }
379 }
380
381 Ok(())
382 }
383
384 fn build_entry(
385 &self,
386 full_path: &Path,
387 relative_path: &str,
388 ) -> Result<FileEntry, FileServiceError> {
389 let metadata = fs::symlink_metadata(full_path)?;
390 let file_kind = classify_kind(&metadata);
391 let name = full_path
392 .file_name()
393 .map(|name| name.to_string_lossy().to_string())
394 .unwrap_or_else(|| "/".to_string());
395
396 Ok(FileEntry {
397 name,
398 relative_path: relative_path.to_string(),
399 parent_relative_path: parent_relative_path(relative_path),
400 is_directory: matches!(file_kind, FileKind::Directory),
401 in_batch: false,
402 kind: file_kind,
403 size_bytes: metadata.len(),
404 modified_unix_seconds: metadata
405 .modified()
406 .ok()
407 .and_then(|value| value.duration_since(UNIX_EPOCH).ok())
408 .map(|value| value.as_secs()),
409 size_label: size_label(&metadata, file_kind),
410 modified_label: modified_label(metadata.modified().ok()),
411 permissions_label: permissions_label(&metadata),
412 })
413 }
414
415 fn collect_download_assets_from(
416 &self,
417 relative_path: &str,
418 assets: &mut Vec<FileAsset>,
419 visited_directories: &mut BTreeSet<PathBuf>,
420 ) -> Result<(), FileServiceError> {
421 let relative_path = sanitize_relative_path(relative_path)?;
422 if relative_path.is_empty() {
423 return Ok(());
424 }
425
426 let resolved_path = self.resolve_existing_path(&relative_path)?;
427 let metadata = fs::metadata(&resolved_path)?;
428
429 if metadata.is_dir() {
430 if !visited_directories.insert(resolved_path.clone()) {
431 return Ok(());
432 }
433
434 for entry in fs::read_dir(&resolved_path)? {
435 let entry = entry?;
436 let child_relative_path = join_relative_path(&relative_path, &entry.file_name());
437 self.collect_download_assets_from(
438 &child_relative_path,
439 assets,
440 visited_directories,
441 )?;
442 }
443
444 return Ok(());
445 }
446
447 if metadata.is_file() {
448 assets.push(self.file_asset(&relative_path)?);
449 return Ok(());
450 }
451
452 Err(FileServiceError::NotAFile(display_relative_path(
453 &relative_path,
454 )))
455 }
456
457 fn collect_move_operations(
458 &self,
459 requested_paths: &[String],
460 ) -> Result<Vec<MoveOperation>, FileServiceError> {
461 let mut operations = Vec::new();
462
463 for relative_path in requested_paths {
464 self.collect_move_operations_from(relative_path, &mut operations)?;
465 }
466
467 Ok(operations)
468 }
469
470 fn collect_move_operations_from(
471 &self,
472 relative_path: &str,
473 operations: &mut Vec<MoveOperation>,
474 ) -> Result<(), FileServiceError> {
475 let resolved_path = self.resolve_existing_path(relative_path)?;
476 let metadata = fs::symlink_metadata(&resolved_path)?;
477
478 if metadata.is_dir() {
479 operations.push(MoveOperation {
480 relative_path: relative_path.to_string(),
481 source_path: resolved_path.clone(),
482 kind: MoveOperationKind::Directory,
483 });
484
485 let mut child_paths = fs::read_dir(&resolved_path)?
486 .map(|entry| {
487 let entry = entry?;
488 Ok(join_relative_path(relative_path, &entry.file_name()))
489 })
490 .collect::<Result<Vec<_>, io::Error>>()?;
491 child_paths.sort();
492
493 for child_relative_path in child_paths {
494 self.collect_move_operations_from(&child_relative_path, operations)?;
495 }
496
497 return Ok(());
498 }
499
500 if metadata.is_file() {
501 operations.push(MoveOperation {
502 relative_path: relative_path.to_string(),
503 source_path: resolved_path,
504 kind: MoveOperationKind::File,
505 });
506 return Ok(());
507 }
508
509 Err(FileServiceError::NotAFile(display_relative_path(
510 relative_path,
511 )))
512 }
513
514 fn resolve_existing_path(&self, relative_path: &str) -> Result<PathBuf, FileServiceError> {
515 let joined = if relative_path.is_empty() {
516 self.root_dir.clone()
517 } else {
518 self.root_dir.join(relative_path)
519 };
520
521 let canonical_path = joined.canonicalize().map_err(|error| match error.kind() {
522 io::ErrorKind::NotFound => {
523 FileServiceError::NotFound(display_relative_path(relative_path))
524 }
525 _ => FileServiceError::Io(error),
526 })?;
527
528 if !canonical_path.starts_with(&self.root_dir) {
529 return Err(FileServiceError::OutsideRoot(display_relative_path(
530 relative_path,
531 )));
532 }
533
534 Ok(canonical_path)
535 }
536
537 fn resolve_destination_path(&self, relative_path: &str) -> Result<PathBuf, FileServiceError> {
538 let sanitized = sanitize_relative_path(relative_path)?;
539 Ok(if sanitized.is_empty() {
540 self.root_dir.clone()
541 } else {
542 self.root_dir.join(sanitized)
543 })
544 }
545
546 fn ensure_move_destinations_available(
547 &self,
548 requested_paths: &[String],
549 target: &FileService,
550 ) -> Result<(), FileServiceError> {
551 if self.root_dir == target.root_dir {
552 return Err(FileServiceError::invalid_path(
553 "Select a different destination mount.",
554 ));
555 }
556
557 for relative_path in requested_paths {
558 let destination_path = target.resolve_destination_path(relative_path)?;
559 if destination_path.exists() {
560 return Err(FileServiceError::AlreadyExists(display_relative_path(
561 relative_path,
562 )));
563 }
564 }
565
566 Ok(())
567 }
568
569 fn cleanup_move_sources(&self, requested_paths: &[String]) -> Result<(), FileServiceError> {
570 let mut cleanup_paths = requested_paths.to_vec();
571 cleanup_paths.sort_by_key(|path| std::cmp::Reverse(path.matches('/').count()));
572
573 for relative_path in cleanup_paths {
574 let source_path = self.resolve_destination_path(&relative_path)?;
575 if !source_path.exists() {
576 continue;
577 }
578
579 if source_path.is_dir() {
580 fs::remove_dir_all(&source_path)?;
581 } else {
582 fs::remove_file(&source_path)?;
583 }
584 }
585
586 Ok(())
587 }
588}
589
590fn sanitize_relative_path(input: &str) -> Result<String, FileServiceError> {
591 let trimmed = input.trim().trim_matches('/');
592
593 if trimmed.is_empty() {
594 return Ok(String::new());
595 }
596
597 let mut parts = Vec::new();
598
599 for component in Path::new(trimmed).components() {
600 match component {
601 Component::CurDir => {}
602 Component::Normal(part) => parts.push(part.to_string_lossy().to_string()),
603 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
604 return Err(FileServiceError::invalid_path(input));
605 }
606 }
607 }
608
609 Ok(parts.join("/"))
610}
611
612fn build_breadcrumbs(relative_path: &str) -> Vec<Breadcrumb> {
613 let mut breadcrumbs = vec![Breadcrumb {
614 name: "Root".to_string(),
615 path: String::new(),
616 }];
617
618 if relative_path.is_empty() {
619 return breadcrumbs;
620 }
621
622 let mut current = String::new();
623 for segment in relative_path.split('/') {
624 if !current.is_empty() {
625 current.push('/');
626 }
627 current.push_str(segment);
628
629 breadcrumbs.push(Breadcrumb {
630 name: segment.to_string(),
631 path: current.clone(),
632 });
633 }
634
635 breadcrumbs
636}
637
638fn join_relative_path(base: &str, file_name: &std::ffi::OsStr) -> String {
639 let name = file_name.to_string_lossy();
640 if base.is_empty() {
641 name.to_string()
642 } else {
643 format!("{base}/{name}")
644 }
645}
646
647fn parent_relative_path(relative_path: &str) -> String {
648 match relative_path.rsplit_once('/') {
649 Some((parent, _)) => parent.to_string(),
650 None => String::new(),
651 }
652}
653
654fn display_relative_path(relative_path: &str) -> String {
655 if relative_path.is_empty() {
656 "/".to_string()
657 } else {
658 format!("/{relative_path}")
659 }
660}
661
662fn normalize_requested_paths(requested_paths: &[String]) -> Result<Vec<String>, FileServiceError> {
663 let mut normalized_paths = requested_paths
664 .iter()
665 .map(|path| sanitize_relative_path(path))
666 .collect::<Result<Vec<_>, _>>()?;
667
668 normalized_paths.retain(|path| !path.is_empty());
669 normalized_paths.sort();
670 normalized_paths.dedup();
671
672 let mut retained_paths = Vec::new();
673 for path in normalized_paths {
674 let already_covered = retained_paths.iter().any(|ancestor: &String| {
675 path == *ancestor
676 || path
677 .strip_prefix(ancestor)
678 .is_some_and(|suffix| suffix.starts_with('/'))
679 });
680
681 if !already_covered {
682 retained_paths.push(path);
683 }
684 }
685
686 Ok(retained_paths)
687}
688
689fn storage_usage_from_totals(total_bytes: u64, available_bytes: u64) -> StorageUsage {
690 let available_bytes = available_bytes.min(total_bytes);
691 let used_bytes = total_bytes.saturating_sub(available_bytes);
692 let used_percent = if total_bytes == 0 {
693 0
694 } else {
695 ((used_bytes.saturating_mul(100)) / total_bytes) as u8
696 };
697
698 StorageUsage {
699 used_bytes,
700 available_bytes,
701 total_bytes,
702 used_percent,
703 used_label: format_byte_size(used_bytes),
704 available_label: format_byte_size(available_bytes),
705 total_label: format_byte_size(total_bytes),
706 }
707}
708
709fn classify_kind(metadata: &Metadata) -> FileKind {
710 let file_type = metadata.file_type();
711
712 if file_type.is_dir() {
713 FileKind::Directory
714 } else if file_type.is_file() {
715 FileKind::File
716 } else if file_type.is_symlink() {
717 FileKind::Symlink
718 } else {
719 FileKind::Other
720 }
721}
722
723fn size_label(metadata: &Metadata, kind: FileKind) -> String {
724 if kind == FileKind::Directory {
725 return "Folder".to_string();
726 }
727
728 format_byte_size(metadata.len())
729}
730
731fn format_byte_size(bytes: u64) -> String {
732 let size = bytes as f64;
733 let units = ["B", "KB", "MB", "GB", "TB"];
734 let mut unit = 0;
735 let mut value = size;
736
737 while value >= 1024.0 && unit < units.len() - 1 {
738 value /= 1024.0;
739 unit += 1;
740 }
741
742 if unit == 0 {
743 format!("{bytes} {}", units[unit])
744 } else {
745 format!("{value:.1} {}", units[unit])
746 }
747}
748
749fn modified_label(modified_time: Option<SystemTime>) -> String {
750 modified_time
751 .map(OffsetDateTime::from)
752 .and_then(|time| time.format(MODIFIED_FORMAT).ok())
753 .unwrap_or_else(|| "Unknown".to_string())
754}
755
756#[cfg(unix)]
757fn permissions_label(metadata: &Metadata) -> String {
758 use std::os::unix::fs::PermissionsExt;
759
760 format!("{:03o}", metadata.permissions().mode() & 0o777)
761}
762
763#[cfg(not(unix))]
764fn permissions_label(metadata: &Metadata) -> String {
765 if metadata.permissions().readonly() {
766 "readonly".to_string()
767 } else {
768 "read/write".to_string()
769 }
770}
771
772fn sort_entries(entries: &mut [FileEntry]) {
773 entries.sort_by(|left, right| {
774 right
775 .is_directory
776 .cmp(&left.is_directory)
777 .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
778 });
779}
780
781#[cfg(test)]
782mod tests {
783 use std::{fs, path::Path};
784
785 use tempfile::tempdir;
786
787 use super::FileService;
788
789 #[test]
790 fn lists_directories_before_files() {
791 let temp_dir = tempdir().unwrap();
792 fs::create_dir(temp_dir.path().join("folder")).unwrap();
793 fs::write(temp_dir.path().join("b.txt"), "world").unwrap();
794 fs::write(temp_dir.path().join("a.txt"), "hello").unwrap();
795
796 let service = FileService::new(temp_dir.path()).unwrap();
797 let listing = service.list_dir("").unwrap();
798
799 assert_eq!(listing.current_path_display, "/");
800 assert_eq!(listing.breadcrumbs[0].name, "Root");
801 assert_eq!(listing.entries[0].name, "folder");
802 assert!(listing.entries[0].is_directory);
803 assert_eq!(listing.entries[1].name, "a.txt");
804 assert_eq!(listing.entries[2].name, "b.txt");
805 }
806
807 #[test]
808 fn rejects_parent_directory_traversal() {
809 let temp_dir = tempdir().unwrap();
810 let service = FileService::new(temp_dir.path()).unwrap();
811
812 let error = service.list_dir("../etc").unwrap_err();
813
814 assert_eq!(
815 error.to_string(),
816 "Path '../etc' is invalid. Relative child paths only."
817 );
818 }
819
820 #[test]
821 fn search_flattens_nested_matches() {
822 let temp_dir = tempdir().unwrap();
823 fs::create_dir_all(temp_dir.path().join(Path::new("images/nested"))).unwrap();
824 fs::write(temp_dir.path().join("images/logo.png"), "png").unwrap();
825 fs::write(temp_dir.path().join("images/nested/hero.png"), "png").unwrap();
826 fs::write(temp_dir.path().join("images/readme.txt"), "text").unwrap();
827
828 let service = FileService::new(temp_dir.path()).unwrap();
829 let results = service.search("png").unwrap();
830
831 assert_eq!(results.total_matches, 2);
832 assert_eq!(results.entries.len(), 2);
833 assert!(
834 results
835 .entries
836 .iter()
837 .all(|entry| entry.relative_path.ends_with(".png"))
838 );
839 }
840
841 #[test]
842 fn resolves_file_assets_with_mime() {
843 let temp_dir = tempdir().unwrap();
844 fs::write(temp_dir.path().join("notes.txt"), "hello").unwrap();
845
846 let service = FileService::new(temp_dir.path()).unwrap();
847 let asset = service.file_asset("notes.txt").unwrap();
848
849 assert_eq!(asset.file_name, "notes.txt");
850 assert_eq!(asset.extension.as_deref(), Some("txt"));
851 assert_eq!(asset.mime_type, "text/plain");
852 }
853
854 #[test]
855 fn deletes_files_and_directories_once() {
856 let temp_dir = tempdir().unwrap();
857 fs::create_dir_all(temp_dir.path().join("nested/child")).unwrap();
858 fs::write(temp_dir.path().join("nested/child/file.txt"), "hello").unwrap();
859 fs::write(temp_dir.path().join("root.txt"), "hello").unwrap();
860
861 let service = FileService::new(temp_dir.path()).unwrap();
862 let summary = service
863 .delete_entries(&[
864 "nested".to_string(),
865 "nested/child/file.txt".to_string(),
866 "root.txt".to_string(),
867 ])
868 .unwrap();
869
870 assert_eq!(summary.deleted_count, 2);
871 assert!(!temp_dir.path().join("nested").exists());
872 assert!(!temp_dir.path().join("root.txt").exists());
873 }
874
875 #[test]
876 fn storage_usage_formats_labels_and_percent() {
877 let usage = super::storage_usage_from_totals(100, 35);
878
879 assert_eq!(usage.used_bytes, 65);
880 assert_eq!(usage.available_bytes, 35);
881 assert_eq!(usage.used_percent, 65);
882 assert_eq!(usage.used_label, "65 B");
883 assert_eq!(usage.available_label, "35 B");
884 assert_eq!(usage.total_label, "100 B");
885 }
886
887 #[test]
888 fn collects_download_assets_from_files_and_directories_once() {
889 let temp_dir = tempdir().unwrap();
890 fs::create_dir_all(temp_dir.path().join("nested/child")).unwrap();
891 fs::write(temp_dir.path().join("nested/child/one.txt"), "one").unwrap();
892 fs::write(temp_dir.path().join("nested/two.txt"), "two").unwrap();
893 fs::write(temp_dir.path().join("root.txt"), "root").unwrap();
894
895 let service = FileService::new(temp_dir.path()).unwrap();
896 let assets = service
897 .collect_download_assets(&[
898 "nested".to_string(),
899 "nested/child/one.txt".to_string(),
900 "root.txt".to_string(),
901 ])
902 .unwrap();
903
904 let relative_paths = assets
905 .iter()
906 .map(|asset| asset.relative_path.as_str())
907 .collect::<Vec<_>>();
908
909 assert_eq!(
910 relative_paths,
911 vec!["nested/child/one.txt", "nested/two.txt", "root.txt"]
912 );
913 }
914
915 #[test]
916 fn moves_file_to_other_root_preserving_relative_path() {
917 let source_dir = tempdir().unwrap();
918 let target_dir = tempdir().unwrap();
919 fs::create_dir_all(source_dir.path().join("lib/src")).unwrap();
920 fs::write(source_dir.path().join("lib/src/main.rs"), "fn main() {}\n").unwrap();
921
922 let source = FileService::new(source_dir.path()).unwrap();
923 let target = FileService::new(target_dir.path()).unwrap();
924
925 let summary = source
926 .move_entries_to(&["lib/src/main.rs".to_string()], &target, |_, _, _| {})
927 .unwrap();
928
929 assert_eq!(summary.moved_count, 1);
930 assert!(!source_dir.path().join("lib/src/main.rs").exists());
931 assert_eq!(
932 fs::read_to_string(target_dir.path().join("lib/src/main.rs")).unwrap(),
933 "fn main() {}\n"
934 );
935 }
936
937 #[test]
938 fn moves_directories_including_empty_children() {
939 let source_dir = tempdir().unwrap();
940 let target_dir = tempdir().unwrap();
941 fs::create_dir_all(source_dir.path().join("nested/child/empty")).unwrap();
942 fs::write(source_dir.path().join("nested/child/file.txt"), "hello").unwrap();
943
944 let source = FileService::new(source_dir.path()).unwrap();
945 let target = FileService::new(target_dir.path()).unwrap();
946
947 source
948 .move_entries_to(&["nested".to_string()], &target, |_, _, _| {})
949 .unwrap();
950
951 assert!(!source_dir.path().join("nested").exists());
952 assert_eq!(
953 fs::read_to_string(target_dir.path().join("nested/child/file.txt")).unwrap(),
954 "hello"
955 );
956 assert!(target_dir.path().join("nested/child/empty").is_dir());
957 }
958}