1use std::fs;
19use std::path::{Path, PathBuf};
20
21mod 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#[derive(Debug, Clone, Default)]
49pub struct LocalFileProvider;
50
51impl LocalFileProvider {
53 pub fn new() -> Self {
63 Self
64 }
65}
66
67impl FileProvider for LocalFileProvider {
69 fn capabilities(&self) -> FileCapabilities {
71 FileCapabilities {
72 watch: true,
73 ..FileCapabilities::READ_WRITE
74 }
75 }
76
77 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 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 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 fn metadata(&self, uri: &FileUri) -> Result<FileMetadata, FileError> {
140 metadata_for_path(&local_path(uri)?)
141 }
142
143 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 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 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 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
210fn local_path(uri: &FileUri) -> Result<PathBuf, FileError> {
217 uri.to_local_path()
218}
219
220fn 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
257fn 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)]
269mod tests {
271 use std::time::{SystemTime, UNIX_EPOCH};
272
273 use super::*;
274
275 struct TempDir {
277 path: PathBuf,
279 }
280
281 impl TempDir {
283 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 fn uri(&self) -> FileUri {
300 FileUri::local(&self.path).expect("temp uri")
301 }
302
303 fn child_uri(&self, name: &str) -> FileUri {
305 FileUri::local(self.path.join(name)).expect("child uri")
306 }
307 }
308
309 impl Drop for TempDir {
311 fn drop(&mut self) {
313 let _ = fs::remove_dir_all(&self.path);
314 }
315 }
316
317 #[test]
318 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 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 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 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 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 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 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 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 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 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}