Skip to main content

slash_files/
fs.rs

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}