ailloli_ui_fs_local/
lib.rs

1//! Local filesystem provider and native non-recursive watcher for `ailloli_ui_fs`.
2//!
3//! [`LocalFileProvider`] performs synchronous host filesystem calls. The
4//! exported source factory creates the worker-owned watcher used by
5//! `ailloli_ui_fs_runtime`; neither layer adds sandboxing or path authorization.
6//!
7//! # Examples
8//!
9//! ```
10//! use ailloli_ui_fs::{FileProvider, FileUri};
11//! use ailloli_ui_fs_local::LocalFileProvider;
12//! let provider = LocalFileProvider::new();
13//! let remote = FileUri::parse("sftp://host/file.txt")?;
14//! assert!(provider.read_file(&remote).is_err());
15//! # Ok::<(), Box<dyn std::error::Error>>(())
16//! ```
17
18use std::fs;
19use std::path::{Path, PathBuf};
20
21/// Worker-owned native-watch source implementation.
22mod source;
23
24pub use source::{
25    LocalFileTreeSource, LocalFileTreeSourceFactory, DEFAULT_LOCAL_FILE_TREE_MAX_WATCHERS,
26};
27
28use ailloli_ui_fs::{
29    FileCapabilities, FileEntry, FileError, FileKind, FileMetadata, FileProvider, FileUri,
30};
31
32/// Stateless synchronous provider for `file:` URIs on the host filesystem.
33///
34/// Paths are neither confined nor normalized before access. Methods inherit OS
35/// permissions, follow symlinks except where explicitly documented, and may
36/// block the calling thread; use `LocalFileTreeSourceFactory` with the runtime
37/// worker for retained file-tree operations.
38///
39/// # Examples
40///
41/// ```
42/// use ailloli_ui_fs::FileProvider;
43/// use ailloli_ui_fs_local::LocalFileProvider;
44/// let provider = LocalFileProvider::new();
45/// assert!(provider.capabilities().read);
46/// assert!(provider.capabilities().watch);
47/// ```
48#[derive(Debug, Clone, Default)]
49pub struct LocalFileProvider;
50
51/// Constructs the stateless local provider.
52impl LocalFileProvider {
53    /// Returns a zero-sized provider with no filesystem access performed yet.
54    ///
55    /// # Examples
56    ///
57    /// ```
58    /// use ailloli_ui_fs_local::LocalFileProvider;
59    /// let provider = LocalFileProvider::new();
60    /// assert_eq!(std::mem::size_of_val(&provider), 0);
61    /// ```
62    pub fn new() -> Self {
63        Self
64    }
65}
66
67/// Maps the generic provider contract to synchronous `std::fs` operations.
68impl FileProvider for LocalFileProvider {
69    /// Advertises read/write plus native watch support.
70    fn capabilities(&self) -> FileCapabilities {
71        FileCapabilities {
72            watch: true,
73            ..FileCapabilities::READ_WRITE
74        }
75    }
76
77    /// Reads one directory level and sorts by name then URI path.
78    ///
79    /// An empty directory returns an empty vector. Per-entry metadata failures
80    /// abort the complete read rather than returning a partial list.
81    ///
82    /// # Errors
83    ///
84    /// Returns [`FileError::InvalidUri`] for a non-local URI or an entry path
85    /// that cannot become a local URI, and maps directory iteration or metadata
86    /// failures through [`FileError::from_io`].
87    fn read_dir(&self, uri: &FileUri) -> Result<Vec<FileEntry>, FileError> {
88        let path = local_path(uri)?;
89        let entries = fs::read_dir(&path)
90            .map_err(|err| FileError::from_io(&err, path.display().to_string()))?;
91        let mut entries = entries
92            .map(|entry| {
93                let entry =
94                    entry.map_err(|err| FileError::from_io(&err, path.display().to_string()))?;
95                let entry_path = entry.path();
96                let uri = FileUri::local(&entry_path)?;
97                let metadata = metadata_for_path(&entry_path)?;
98                Ok(FileEntry::new(uri, metadata))
99            })
100            .collect::<Result<Vec<_>, FileError>>()?;
101        entries.sort_by(|a, b| {
102            a.name
103                .cmp(&b.name)
104                .then_with(|| a.uri.path().cmp(b.uri.path()))
105        });
106        Ok(entries)
107    }
108
109    /// Reads the complete file into an owned byte vector.
110    ///
111    /// # Errors
112    ///
113    /// Returns [`FileError::InvalidUri`] for a non-local URI and maps the host
114    /// read failure through [`FileError::from_io`].
115    fn read_file(&self, uri: &FileUri) -> Result<Vec<u8>, FileError> {
116        let path = local_path(uri)?;
117        fs::read(&path).map_err(|err| FileError::from_io(&err, path.display().to_string()))
118    }
119
120    /// Creates or truncates a file and writes `bytes` non-atomically.
121    ///
122    /// Empty bytes create an empty file. Missing parents are not created.
123    ///
124    /// # Errors
125    ///
126    /// Returns [`FileError::InvalidUri`] for a non-local URI and maps create,
127    /// truncate, or write failures through [`FileError::from_io`].
128    fn write_file(&self, uri: &FileUri, bytes: &[u8]) -> Result<(), FileError> {
129        let path = local_path(uri)?;
130        fs::write(&path, bytes).map_err(|err| FileError::from_io(&err, path.display().to_string()))
131    }
132
133    /// Returns symlink-aware metadata without following the link for `kind`.
134    ///
135    /// # Errors
136    ///
137    /// Returns [`FileError::InvalidUri`] for a non-local URI or a mapped host
138    /// metadata error when the entry itself cannot be inspected.
139    fn metadata(&self, uri: &FileUri) -> Result<FileMetadata, FileError> {
140        metadata_for_path(&local_path(uri)?)
141    }
142
143    /// Resolves the OS canonical path, returning `None` for any failure.
144    ///
145    /// This deliberately collapses missing paths, permission failures, and
146    /// other canonicalization errors to absence after URI validation succeeds.
147    ///
148    /// # Errors
149    ///
150    /// Returns [`FileError::InvalidUri`] when the source is not local or a
151    /// successful canonical host path cannot be represented as a local URI.
152    fn canonical_uri(&self, uri: &FileUri) -> Result<Option<FileUri>, FileError> {
153        let path = local_path(uri)?;
154        match fs::canonicalize(&path) {
155            Ok(canonical) => FileUri::local(canonical).map(Some),
156            Err(_) => Ok(None),
157        }
158    }
159
160    /// Creates exactly one directory; parents are not created recursively.
161    ///
162    /// # Errors
163    ///
164    /// Returns [`FileError::InvalidUri`] for a non-local URI and maps the host
165    /// creation failure through [`FileError::from_io`].
166    fn create_dir(&self, uri: &FileUri) -> Result<(), FileError> {
167        let path = local_path(uri)?;
168        fs::create_dir(&path).map_err(|err| FileError::from_io(&err, path.display().to_string()))
169    }
170
171    /// Delegates an OS rename/move without cross-filesystem fallback.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`FileError::InvalidUri`] when either URI is non-local and maps
176    /// every host rename failure, including cross-device moves, through
177    /// [`FileError::from_io`].
178    fn rename(&self, from: &FileUri, to: &FileUri) -> Result<(), FileError> {
179        let from = local_path(from)?;
180        let to = local_path(to)?;
181        fs::rename(&from, &to).map_err(|err| {
182            FileError::from_io(&err, format!("{} -> {}", from.display(), to.display()))
183        })
184    }
185
186    /// Removes one file, symlink, or empty directory non-recursively.
187    ///
188    /// `symlink_metadata` ensures a link to a directory removes the link with
189    /// `remove_file`, never the target directory.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`FileError::InvalidUri`] for a non-local URI and maps metadata,
194    /// file removal, or empty-directory removal failures through
195    /// [`FileError::from_io`].
196    fn remove(&self, uri: &FileUri) -> Result<(), FileError> {
197        let path = local_path(uri)?;
198        let metadata = fs::symlink_metadata(&path)
199            .map_err(|err| FileError::from_io(&err, path.display().to_string()))?;
200        if metadata.is_dir() {
201            fs::remove_dir(&path)
202                .map_err(|err| FileError::from_io(&err, path.display().to_string()))
203        } else {
204            fs::remove_file(&path)
205                .map_err(|err| FileError::from_io(&err, path.display().to_string()))
206        }
207    }
208}
209
210/// Converts only a local `file:` URI to its host path.
211///
212/// # Errors
213///
214/// Propagates [`FileError::InvalidUri`] when `uri` is not an authority-free
215/// local `file:` URI.
216fn local_path(uri: &FileUri) -> Result<PathBuf, FileError> {
217    uri.to_local_path()
218}
219
220/// Reads link metadata and best-effort target kind for one host path.
221///
222/// # Errors
223///
224/// Maps failure to inspect the entry itself through [`FileError::from_io`]. A
225/// failure to follow a symlink target is intentionally reduced to unknown
226/// target kind instead of returned.
227fn metadata_for_path(path: &Path) -> Result<FileMetadata, FileError> {
228    let metadata = fs::symlink_metadata(path)
229        .map_err(|err| FileError::from_io(&err, path.display().to_string()))?;
230    let file_type = metadata.file_type();
231    let kind = if file_type.is_symlink() {
232        FileKind::Symlink
233    } else if file_type.is_dir() {
234        FileKind::Directory
235    } else if file_type.is_file() {
236        FileKind::File
237    } else {
238        FileKind::Other
239    };
240    let symlink_target_kind = if file_type.is_symlink() {
241        fs::metadata(path)
242            .ok()
243            .map(|target_metadata| kind_for_target_type(target_metadata.file_type()))
244    } else {
245        None
246    };
247    Ok(FileMetadata {
248        kind,
249        symlink_target_kind,
250        len: metadata.len(),
251        readonly: metadata.permissions().readonly(),
252        modified: metadata.modified().ok(),
253        created: metadata.created().ok(),
254    })
255}
256
257/// Classifies a followed symlink target as directory, file, or other.
258fn kind_for_target_type(file_type: fs::FileType) -> FileKind {
259    if file_type.is_dir() {
260        FileKind::Directory
261    } else if file_type.is_file() {
262        FileKind::File
263    } else {
264        FileKind::Other
265    }
266}
267
268#[cfg(test)]
269/// Local I/O and Unix symlink regression tests.
270mod tests {
271    use std::time::{SystemTime, UNIX_EPOCH};
272
273    use super::*;
274
275    /// Recoverable unique temporary directory for one test.
276    struct TempDir {
277        /// Host path removed recursively on drop.
278        path: PathBuf,
279    }
280
281    /// Temporary path and URI helpers.
282    impl TempDir {
283        /// Creates a process/time-qualified directory in the OS temp root.
284        fn new(name: &str) -> Self {
285            let nanos = SystemTime::now()
286                .duration_since(UNIX_EPOCH)
287                .expect("time")
288                .as_nanos();
289            let path = std::env::temp_dir().join(format!(
290                "ailloli_ui_fs_local_{name}_{}_{}",
291                std::process::id(),
292                nanos
293            ));
294            fs::create_dir(&path).expect("temp dir");
295            Self { path }
296        }
297
298        /// Converts the temporary root to a local file URI.
299        fn uri(&self) -> FileUri {
300            FileUri::local(&self.path).expect("temp uri")
301        }
302
303        /// Converts one direct child name to a local file URI.
304        fn child_uri(&self, name: &str) -> FileUri {
305            FileUri::local(self.path.join(name)).expect("child uri")
306        }
307    }
308
309    /// Recursively removes the test directory on scope exit.
310    impl Drop for TempDir {
311        /// Performs best-effort cleanup without masking a test result.
312        fn drop(&mut self) {
313            let _ = fs::remove_dir_all(&self.path);
314        }
315    }
316
317    #[test]
318    /// Verifies deterministic name ordering and directory classification.
319    fn reads_and_sorts_directory_entries() {
320        let temp = TempDir::new("read_dir");
321        fs::write(temp.path.join("b.txt"), b"b").expect("b");
322        fs::write(temp.path.join("a.txt"), b"a").expect("a");
323        fs::create_dir(temp.path.join("dir")).expect("dir");
324
325        let provider = LocalFileProvider::new();
326        let entries = provider.read_dir(&temp.uri()).expect("entries");
327        let names = entries
328            .iter()
329            .map(|entry| entry.name.as_str())
330            .collect::<Vec<_>>();
331        assert_eq!(names, ["a.txt", "b.txt", "dir"]);
332        assert_eq!(entries[2].metadata.kind, FileKind::Directory);
333    }
334
335    #[test]
336    /// Verifies byte preservation and exact file length metadata.
337    fn roundtrips_file_bytes_and_metadata() {
338        let temp = TempDir::new("roundtrip");
339        let file = temp.child_uri("data.bin");
340        let provider = LocalFileProvider::new();
341        let payload = b"ailloli_ui";
342
343        provider.write_file(&file, payload).expect("write");
344        assert_eq!(provider.read_file(&file).expect("read"), payload);
345        let metadata = provider.metadata(&file).expect("metadata");
346        assert_eq!(metadata.kind, FileKind::File);
347        assert_eq!(metadata.len, payload.len() as u64);
348    }
349
350    #[test]
351    /// Verifies nonrecursive create, rename, and removal operations.
352    fn creates_renames_and_removes_entries() {
353        let temp = TempDir::new("ops");
354        let provider = LocalFileProvider::new();
355        let dir = temp.child_uri("created");
356        let from = temp.child_uri("from.txt");
357        let to = temp.child_uri("to.txt");
358
359        provider.create_dir(&dir).expect("create dir");
360        assert_eq!(
361            provider.metadata(&dir).expect("dir metadata").kind,
362            FileKind::Directory
363        );
364        provider.write_file(&from, b"x").expect("write");
365        provider.rename(&from, &to).expect("rename");
366        assert!(matches!(
367            provider.read_file(&from),
368            Err(FileError::NotFound(_))
369        ));
370        assert_eq!(provider.read_file(&to).expect("renamed"), b"x");
371        provider.remove(&to).expect("remove file");
372        provider.remove(&dir).expect("remove dir");
373        assert!(matches!(
374            provider.metadata(&to),
375            Err(FileError::NotFound(_))
376        ));
377    }
378
379    #[test]
380    /// Verifies generic recursive copy, move, and removal through this provider.
381    fn copy_move_recursive_entries_through_provider_api() {
382        let temp = TempDir::new("copy_move_recursive");
383        let provider = LocalFileProvider::new();
384        fs::create_dir(temp.path.join("src")).expect("src");
385        fs::create_dir(temp.path.join("src/nested")).expect("nested");
386        fs::write(temp.path.join("src/main.rs"), b"main").expect("main");
387        fs::write(temp.path.join("src/nested/lib.rs"), b"lib").expect("lib");
388
389        let src = temp.child_uri("src");
390        let copied = temp.child_uri("copied");
391        provider.copy_entry(&src, &copied).expect("copy tree");
392        assert_eq!(
393            fs::read(temp.path.join("copied/main.rs")).expect("copied main"),
394            b"main"
395        );
396        assert_eq!(
397            fs::read(temp.path.join("copied/nested/lib.rs")).expect("copied lib"),
398            b"lib"
399        );
400
401        let moved = temp.child_uri("moved");
402        provider.move_entry(&copied, &moved).expect("move tree");
403        assert!(!temp.path.join("copied").exists());
404        assert_eq!(
405            fs::read(temp.path.join("moved/nested/lib.rs")).expect("moved lib"),
406            b"lib"
407        );
408
409        provider
410            .remove_recursive(&moved)
411            .expect("remove recursive tree");
412        assert!(!temp.path.join("moved").exists());
413    }
414
415    #[test]
416    /// Verifies scheme rejection and missing-file error classification.
417    fn rejects_non_file_uri_and_reports_missing_file() {
418        let provider = LocalFileProvider::new();
419        let remote = FileUri::parse("sftp://host/tmp/a.txt").expect("remote");
420        assert!(matches!(
421            provider.read_file(&remote),
422            Err(FileError::UnsupportedScheme(_))
423        ));
424
425        let temp = TempDir::new("missing");
426        assert!(matches!(
427            provider.read_file(&temp.child_uri("missing.txt")),
428            Err(FileError::NotFound(_))
429        ));
430    }
431
432    #[cfg(unix)]
433    #[test]
434    /// Verifies directory symlink kind and followed target-kind metadata.
435    fn symlink_to_directory_keeps_link_kind_and_records_target_directory() {
436        let temp = TempDir::new("symlink_dir");
437        fs::create_dir(temp.path.join("target")).expect("target dir");
438        std::os::unix::fs::symlink("target", temp.path.join("linked")).expect("symlink");
439
440        let provider = LocalFileProvider::new();
441        let metadata = provider
442            .metadata(&temp.child_uri("linked"))
443            .expect("symlink metadata");
444
445        assert_eq!(metadata.kind, FileKind::Symlink);
446        assert_eq!(metadata.symlink_target_kind, Some(FileKind::Directory));
447        assert!(metadata.is_directory_like());
448    }
449
450    #[cfg(unix)]
451    #[test]
452    /// Verifies file symlink kind and followed target-kind metadata.
453    fn symlink_to_file_keeps_link_kind_and_records_target_file() {
454        let temp = TempDir::new("symlink_file");
455        fs::write(temp.path.join("target.txt"), b"target").expect("target file");
456        std::os::unix::fs::symlink("target.txt", temp.path.join("linked.txt")).expect("symlink");
457
458        let provider = LocalFileProvider::new();
459        let metadata = provider
460            .metadata(&temp.child_uri("linked.txt"))
461            .expect("symlink metadata");
462
463        assert_eq!(metadata.kind, FileKind::Symlink);
464        assert_eq!(metadata.symlink_target_kind, Some(FileKind::File));
465        assert!(metadata.is_file_like());
466    }
467
468    #[cfg(unix)]
469    #[test]
470    /// Verifies broken symlink metadata preserves link kind with no target kind.
471    fn broken_symlink_keeps_link_kind_without_target_kind() {
472        let temp = TempDir::new("broken_symlink");
473        std::os::unix::fs::symlink("missing", temp.path.join("broken")).expect("symlink");
474
475        let provider = LocalFileProvider::new();
476        let metadata = provider
477            .metadata(&temp.child_uri("broken"))
478            .expect("symlink metadata");
479
480        assert_eq!(metadata.kind, FileKind::Symlink);
481        assert_eq!(metadata.symlink_target_kind, None);
482        assert!(!metadata.is_directory_like());
483    }
484
485    #[cfg(unix)]
486    #[test]
487    /// Verifies removing a directory symlink leaves its target intact.
488    fn removing_symlink_to_directory_removes_link_not_target() {
489        let temp = TempDir::new("remove_symlink_dir");
490        fs::create_dir(temp.path.join("target")).expect("target dir");
491        std::os::unix::fs::symlink("target", temp.path.join("linked")).expect("symlink");
492
493        let provider = LocalFileProvider::new();
494        provider
495            .remove(&temp.child_uri("linked"))
496            .expect("remove symlink");
497
498        assert!(temp.path.join("target").is_dir());
499        assert!(!temp.path.join("linked").exists());
500    }
501
502    #[cfg(unix)]
503    #[test]
504    /// Verifies canonicalization follows a directory symlink to its target.
505    fn canonical_uri_resolves_symlink_to_directory_target() {
506        let temp = TempDir::new("canonical_symlink");
507        fs::create_dir(temp.path.join("target")).expect("target dir");
508        std::os::unix::fs::symlink("target", temp.path.join("linked")).expect("symlink");
509
510        let provider = LocalFileProvider::new();
511        let canonical = provider
512            .canonical_uri(&temp.child_uri("linked"))
513            .expect("canonical uri")
514            .expect("canonical value");
515
516        assert_eq!(
517            canonical.to_local_path().expect("canonical path"),
518            temp.path.join("target")
519        );
520    }
521}