tsk/context/
file_system.rs

1use anyhow::Result;
2use async_trait::async_trait;
3use std::path::Path;
4
5#[async_trait]
6pub trait FileSystemOperations: Send + Sync {
7    async fn create_dir(&self, path: &Path) -> Result<()>;
8    async fn copy_dir(&self, from: &Path, to: &Path) -> Result<()>;
9    async fn write_file(&self, path: &Path, content: &str) -> Result<()>;
10    async fn read_file(&self, path: &Path) -> Result<String>;
11    async fn exists(&self, path: &Path) -> Result<bool>;
12    async fn remove_dir(&self, path: &Path) -> Result<()>;
13    async fn remove_file(&self, path: &Path) -> Result<()>;
14    async fn copy_file(&self, from: &Path, to: &Path) -> Result<()>;
15    async fn read_dir(&self, path: &Path) -> Result<Vec<std::path::PathBuf>>;
16}
17
18pub struct DefaultFileSystem;
19
20#[async_trait]
21impl FileSystemOperations for DefaultFileSystem {
22    async fn create_dir(&self, path: &Path) -> Result<()> {
23        tokio::fs::create_dir_all(path).await?;
24        Ok(())
25    }
26
27    async fn copy_dir(&self, from: &Path, to: &Path) -> Result<()> {
28        self.create_dir(to).await?;
29
30        let mut entries = tokio::fs::read_dir(from).await?;
31        while let Some(entry) = entries.next_entry().await? {
32            let path = entry.path();
33            let relative_path = path.strip_prefix(from)?;
34            let dst_path = to.join(relative_path);
35
36            if entry.file_type().await?.is_dir() {
37                self.copy_dir(&path, &dst_path).await?;
38            } else {
39                if let Some(parent) = dst_path.parent() {
40                    self.create_dir(parent).await?;
41                }
42                self.copy_file(&path, &dst_path).await?;
43            }
44        }
45
46        Ok(())
47    }
48
49    async fn write_file(&self, path: &Path, content: &str) -> Result<()> {
50        if let Some(parent) = path.parent() {
51            self.create_dir(parent).await?;
52        }
53        tokio::fs::write(path, content).await?;
54        Ok(())
55    }
56
57    async fn read_file(&self, path: &Path) -> Result<String> {
58        let content = tokio::fs::read_to_string(path).await?;
59        Ok(content)
60    }
61
62    async fn exists(&self, path: &Path) -> Result<bool> {
63        Ok(tokio::fs::try_exists(path).await.unwrap_or(false))
64    }
65
66    async fn remove_dir(&self, path: &Path) -> Result<()> {
67        tokio::fs::remove_dir_all(path).await?;
68        Ok(())
69    }
70
71    async fn remove_file(&self, path: &Path) -> Result<()> {
72        tokio::fs::remove_file(path).await?;
73        Ok(())
74    }
75
76    async fn copy_file(&self, from: &Path, to: &Path) -> Result<()> {
77        tokio::fs::copy(from, to).await?;
78        Ok(())
79    }
80
81    async fn read_dir(&self, path: &Path) -> Result<Vec<std::path::PathBuf>> {
82        let mut entries = tokio::fs::read_dir(path).await?;
83        let mut paths = Vec::new();
84
85        while let Some(entry) = entries.next_entry().await? {
86            paths.push(entry.path());
87        }
88
89        Ok(paths)
90    }
91}
92
93#[cfg(test)]
94pub(crate) mod tests {
95    use super::*;
96    use std::collections::HashMap;
97    use std::path::PathBuf;
98    use std::sync::{Arc, Mutex};
99
100    #[derive(Clone)]
101    pub struct MockFileSystem {
102        files: Arc<Mutex<HashMap<String, String>>>,
103        dirs: Arc<Mutex<Vec<String>>>,
104    }
105
106    impl MockFileSystem {
107        pub fn new() -> Self {
108            Self {
109                files: Arc::new(Mutex::new(HashMap::new())),
110                dirs: Arc::new(Mutex::new(Vec::new())),
111            }
112        }
113
114        pub fn with_file(self, path: &str, content: &str) -> Self {
115            self.files
116                .lock()
117                .unwrap()
118                .insert(path.to_string(), content.to_string());
119            self
120        }
121
122        pub fn with_dir(self, path: &str) -> Self {
123            self.dirs.lock().unwrap().push(path.to_string());
124            self
125        }
126
127        pub fn get_files(&self) -> HashMap<String, String> {
128            self.files.lock().unwrap().clone()
129        }
130
131        pub fn get_dirs(&self) -> Vec<String> {
132            self.dirs.lock().unwrap().clone()
133        }
134
135        pub fn set_files(&self, files: HashMap<PathBuf, String>) {
136            let mut file_map = self.files.lock().unwrap();
137            file_map.clear();
138            for (path, content) in files {
139                let path_str = path.to_string_lossy().to_string();
140                if content == "dir" {
141                    self.dirs.lock().unwrap().push(path_str);
142                } else {
143                    file_map.insert(path_str, content);
144                }
145            }
146        }
147    }
148
149    #[async_trait]
150    impl FileSystemOperations for MockFileSystem {
151        async fn create_dir(&self, path: &Path) -> Result<()> {
152            let path_str = path.to_string_lossy().to_string();
153            self.dirs.lock().unwrap().push(path_str);
154            Ok(())
155        }
156
157        async fn copy_dir(&self, from: &Path, to: &Path) -> Result<()> {
158            let from_str = from.to_string_lossy().to_string();
159            let to_str = to.to_string_lossy().to_string();
160
161            // Copy all files
162            let files = self.files.lock().unwrap();
163            let mut new_files = HashMap::new();
164
165            for (path, content) in files.iter() {
166                if path.starts_with(&from_str) {
167                    let relative = path.strip_prefix(&from_str).unwrap();
168                    let new_path = format!("{to_str}{relative}");
169                    new_files.insert(new_path, content.clone());
170                }
171            }
172
173            drop(files);
174            self.files.lock().unwrap().extend(new_files);
175
176            // Copy all directories
177            let dirs = self.dirs.lock().unwrap();
178            let mut new_dirs = Vec::new();
179
180            for dir in dirs.iter() {
181                if dir.starts_with(&from_str) {
182                    let relative = dir.strip_prefix(&from_str).unwrap();
183                    let new_dir = format!("{to_str}{relative}");
184                    new_dirs.push(new_dir);
185                }
186            }
187
188            drop(dirs);
189            self.dirs.lock().unwrap().extend(new_dirs);
190            self.dirs.lock().unwrap().push(to_str);
191            Ok(())
192        }
193
194        async fn write_file(&self, path: &Path, content: &str) -> Result<()> {
195            let path_str = path.to_string_lossy().to_string();
196            self.files
197                .lock()
198                .unwrap()
199                .insert(path_str, content.to_string());
200            Ok(())
201        }
202
203        async fn read_file(&self, path: &Path) -> Result<String> {
204            let path_str = path.to_string_lossy().to_string();
205            self.files
206                .lock()
207                .unwrap()
208                .get(&path_str)
209                .cloned()
210                .ok_or_else(|| anyhow::anyhow!("File not found: {}", path_str))
211        }
212
213        async fn exists(&self, path: &Path) -> Result<bool> {
214            let path_str = path.to_string_lossy().to_string();
215            let files = self.files.lock().unwrap();
216            let dirs = self.dirs.lock().unwrap();
217            Ok(files.contains_key(&path_str) || dirs.contains(&path_str))
218        }
219
220        async fn remove_dir(&self, path: &Path) -> Result<()> {
221            let path_str = path.to_string_lossy().to_string();
222            self.dirs
223                .lock()
224                .unwrap()
225                .retain(|p| !p.starts_with(&path_str));
226            self.files
227                .lock()
228                .unwrap()
229                .retain(|p, _| !p.starts_with(&path_str));
230            Ok(())
231        }
232
233        async fn remove_file(&self, path: &Path) -> Result<()> {
234            let path_str = path.to_string_lossy().to_string();
235            self.files
236                .lock()
237                .unwrap()
238                .remove(&path_str)
239                .ok_or_else(|| anyhow::anyhow!("File not found: {}", path_str))?;
240            Ok(())
241        }
242
243        async fn copy_file(&self, from: &Path, to: &Path) -> Result<()> {
244            let from_str = from.to_string_lossy().to_string();
245            let to_str = to.to_string_lossy().to_string();
246
247            let content = self
248                .files
249                .lock()
250                .unwrap()
251                .get(&from_str)
252                .cloned()
253                .ok_or_else(|| anyhow::anyhow!("Source file not found: {}", from_str))?;
254
255            self.files.lock().unwrap().insert(to_str, content);
256            Ok(())
257        }
258
259        async fn read_dir(&self, path: &Path) -> Result<Vec<std::path::PathBuf>> {
260            let path_str = path.to_string_lossy().to_string();
261            let files = self.files.lock().unwrap();
262            let dirs = self.dirs.lock().unwrap();
263
264            // Check if the directory exists
265            if !dirs.contains(&path_str) {
266                return Err(anyhow::anyhow!("Directory not found: {}", path_str));
267            }
268
269            let mut entries = Vec::new();
270
271            for file_path in files.keys() {
272                if file_path.starts_with(&path_str) && file_path != &path_str {
273                    let relative = file_path
274                        .strip_prefix(&path_str)
275                        .unwrap()
276                        .trim_start_matches('/');
277                    if !relative.contains('/') {
278                        entries.push(std::path::PathBuf::from(file_path));
279                    }
280                }
281            }
282
283            for dir_path in dirs.iter() {
284                if dir_path.starts_with(&path_str) && dir_path != &path_str {
285                    let relative = dir_path
286                        .strip_prefix(&path_str)
287                        .unwrap()
288                        .trim_start_matches('/');
289                    if !relative.contains('/') {
290                        entries.push(std::path::PathBuf::from(dir_path));
291                    }
292                }
293            }
294
295            Ok(entries)
296        }
297    }
298
299    #[cfg(test)]
300    mod integration_tests {
301        use super::*;
302        use std::path::Path;
303        use std::sync::Arc;
304        use tempfile::TempDir;
305
306        #[tokio::test]
307        async fn test_default_file_system_create_and_read_file() {
308            let temp_dir = TempDir::new().unwrap();
309            let fs = DefaultFileSystem;
310
311            let file_path = temp_dir.path().join("test.txt");
312            let content = "Hello, world!";
313
314            // Write file
315            fs.write_file(&file_path, content).await.unwrap();
316
317            // Read file
318            let read_content = fs.read_file(&file_path).await.unwrap();
319            assert_eq!(read_content, content);
320
321            // Check exists
322            assert!(fs.exists(&file_path).await.unwrap());
323        }
324
325        #[tokio::test]
326        async fn test_default_file_system_create_dir() {
327            let temp_dir = TempDir::new().unwrap();
328            let fs = DefaultFileSystem;
329
330            let dir_path = temp_dir.path().join("test_dir");
331
332            // Create directory
333            fs.create_dir(&dir_path).await.unwrap();
334
335            // Check exists
336            assert!(fs.exists(&dir_path).await.unwrap());
337        }
338
339        #[tokio::test]
340        async fn test_default_file_system_copy_file() {
341            let temp_dir = TempDir::new().unwrap();
342            let fs = DefaultFileSystem;
343
344            let source_path = temp_dir.path().join("source.txt");
345            let dest_path = temp_dir.path().join("dest.txt");
346            let content = "Test content";
347
348            // Create source file
349            fs.write_file(&source_path, content).await.unwrap();
350
351            // Copy file
352            fs.copy_file(&source_path, &dest_path).await.unwrap();
353
354            // Verify both files exist and have same content
355            assert!(fs.exists(&source_path).await.unwrap());
356            assert!(fs.exists(&dest_path).await.unwrap());
357
358            let dest_content = fs.read_file(&dest_path).await.unwrap();
359            assert_eq!(dest_content, content);
360        }
361
362        #[tokio::test]
363        async fn test_default_file_system_copy_dir() {
364            let temp_dir = TempDir::new().unwrap();
365            let fs = DefaultFileSystem;
366
367            let source_dir = temp_dir.path().join("source_dir");
368            let dest_dir = temp_dir.path().join("dest_dir");
369
370            // Create source directory structure
371            fs.create_dir(&source_dir).await.unwrap();
372            fs.write_file(&source_dir.join("file1.txt"), "content1")
373                .await
374                .unwrap();
375            fs.create_dir(&source_dir.join("subdir")).await.unwrap();
376            fs.write_file(&source_dir.join("subdir").join("file2.txt"), "content2")
377                .await
378                .unwrap();
379
380            // Copy directory
381            fs.copy_dir(&source_dir, &dest_dir).await.unwrap();
382
383            // Verify structure
384            assert!(fs.exists(&dest_dir).await.unwrap());
385            assert!(fs.exists(&dest_dir.join("file1.txt")).await.unwrap());
386            assert!(fs.exists(&dest_dir.join("subdir")).await.unwrap());
387            assert!(
388                fs.exists(&dest_dir.join("subdir").join("file2.txt"))
389                    .await
390                    .unwrap()
391            );
392
393            // Verify content
394            let content1 = fs.read_file(&dest_dir.join("file1.txt")).await.unwrap();
395            assert_eq!(content1, "content1");
396
397            let content2 = fs
398                .read_file(&dest_dir.join("subdir").join("file2.txt"))
399                .await
400                .unwrap();
401            assert_eq!(content2, "content2");
402        }
403
404        #[tokio::test]
405        async fn test_default_file_system_read_dir() {
406            let temp_dir = TempDir::new().unwrap();
407            let fs = DefaultFileSystem;
408
409            let test_dir = temp_dir.path().join("test_dir");
410            fs.create_dir(&test_dir).await.unwrap();
411
412            // Create some files and directories
413            fs.write_file(&test_dir.join("file1.txt"), "content1")
414                .await
415                .unwrap();
416            fs.write_file(&test_dir.join("file2.txt"), "content2")
417                .await
418                .unwrap();
419            fs.create_dir(&test_dir.join("subdir")).await.unwrap();
420
421            // Read directory
422            let entries = fs.read_dir(&test_dir).await.unwrap();
423
424            // Should have 3 entries
425            assert_eq!(entries.len(), 3);
426
427            // Check that all expected paths are present
428            let entry_names: Vec<String> = entries
429                .iter()
430                .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
431                .collect();
432
433            assert!(entry_names.contains(&"file1.txt".to_string()));
434            assert!(entry_names.contains(&"file2.txt".to_string()));
435            assert!(entry_names.contains(&"subdir".to_string()));
436        }
437
438        #[tokio::test]
439        async fn test_default_file_system_remove_dir() {
440            let temp_dir = TempDir::new().unwrap();
441            let fs = DefaultFileSystem;
442
443            let test_dir = temp_dir.path().join("test_dir");
444
445            // Create directory with content
446            fs.create_dir(&test_dir).await.unwrap();
447            fs.write_file(&test_dir.join("file.txt"), "content")
448                .await
449                .unwrap();
450
451            // Verify it exists
452            assert!(fs.exists(&test_dir).await.unwrap());
453
454            // Remove directory
455            fs.remove_dir(&test_dir).await.unwrap();
456
457            // Verify it's gone
458            assert!(!fs.exists(&test_dir).await.unwrap());
459        }
460
461        #[tokio::test]
462        async fn test_mock_file_system() {
463            let mock_fs = MockFileSystem::new()
464                .with_file("/test/file.txt", "test content")
465                .with_dir("/test");
466
467            // Test file exists
468            assert!(mock_fs.exists(Path::new("/test/file.txt")).await.unwrap());
469
470            // Test dir exists
471            assert!(mock_fs.exists(Path::new("/test")).await.unwrap());
472
473            // Test read file
474            let content = mock_fs
475                .read_file(Path::new("/test/file.txt"))
476                .await
477                .unwrap();
478            assert_eq!(content, "test content");
479
480            // Test write file
481            mock_fs
482                .write_file(Path::new("/test/new.txt"), "new content")
483                .await
484                .unwrap();
485            let new_content = mock_fs.read_file(Path::new("/test/new.txt")).await.unwrap();
486            assert_eq!(new_content, "new content");
487
488            // Test create dir
489            mock_fs.create_dir(Path::new("/new_dir")).await.unwrap();
490            assert!(mock_fs.exists(Path::new("/new_dir")).await.unwrap());
491
492            // Test copy file
493            mock_fs
494                .copy_file(Path::new("/test/file.txt"), Path::new("/test/copy.txt"))
495                .await
496                .unwrap();
497            let copied = mock_fs
498                .read_file(Path::new("/test/copy.txt"))
499                .await
500                .unwrap();
501            assert_eq!(copied, "test content");
502
503            // Test remove dir
504            mock_fs.remove_dir(Path::new("/test")).await.unwrap();
505            assert!(!mock_fs.exists(Path::new("/test")).await.unwrap());
506            assert!(!mock_fs.exists(Path::new("/test/file.txt")).await.unwrap());
507        }
508
509        #[tokio::test]
510        async fn test_mock_file_system_copy_dir() {
511            let mock_fs = MockFileSystem::new()
512                .with_dir("/source")
513                .with_file("/source/file1.txt", "content1")
514                .with_dir("/source/subdir")
515                .with_file("/source/subdir/file2.txt", "content2");
516
517            // Copy directory
518            mock_fs
519                .copy_dir(Path::new("/source"), Path::new("/dest"))
520                .await
521                .unwrap();
522
523            // Verify files were copied
524            assert!(mock_fs.exists(Path::new("/dest")).await.unwrap());
525            assert_eq!(
526                mock_fs
527                    .read_file(Path::new("/dest/file1.txt"))
528                    .await
529                    .unwrap(),
530                "content1"
531            );
532            assert_eq!(
533                mock_fs
534                    .read_file(Path::new("/dest/subdir/file2.txt"))
535                    .await
536                    .unwrap(),
537                "content2"
538            );
539        }
540
541        #[tokio::test]
542        async fn test_mock_file_system_read_dir() {
543            let mock_fs = MockFileSystem::new()
544                .with_dir("/test")
545                .with_file("/test/file1.txt", "content1")
546                .with_file("/test/file2.txt", "content2")
547                .with_dir("/test/subdir")
548                .with_file("/test/subdir/nested.txt", "nested");
549
550            // Read directory
551            let entries = mock_fs.read_dir(Path::new("/test")).await.unwrap();
552
553            // Should have 3 entries (2 files + 1 dir at the top level)
554            assert_eq!(entries.len(), 3);
555
556            // Check that entries are correct
557            let entry_strs: Vec<String> = entries
558                .iter()
559                .map(|p| p.to_string_lossy().to_string())
560                .collect();
561
562            assert!(entry_strs.contains(&"/test/file1.txt".to_string()));
563            assert!(entry_strs.contains(&"/test/file2.txt".to_string()));
564            assert!(entry_strs.contains(&"/test/subdir".to_string()));
565
566            // Should not include nested file
567            assert!(!entry_strs.contains(&"/test/subdir/nested.txt".to_string()));
568        }
569
570        #[tokio::test]
571        async fn test_default_file_system_remove_file() {
572            let temp_dir = TempDir::new().unwrap();
573            let fs = DefaultFileSystem;
574
575            let file_path = temp_dir.path().join("test.txt");
576            let content = "Test content";
577
578            // Create file
579            fs.write_file(&file_path, content).await.unwrap();
580
581            // Verify it exists
582            assert!(fs.exists(&file_path).await.unwrap());
583
584            // Remove file
585            fs.remove_file(&file_path).await.unwrap();
586
587            // Verify it's gone
588            assert!(!fs.exists(&file_path).await.unwrap());
589
590            // Try to remove non-existent file - should error
591            let result = fs.remove_file(&file_path).await;
592            assert!(result.is_err());
593        }
594
595        #[tokio::test]
596        async fn test_mock_file_system_remove_file() {
597            let mock_fs = MockFileSystem::new().with_file("/test/file.txt", "test content");
598
599            // Verify file exists
600            assert!(mock_fs.exists(Path::new("/test/file.txt")).await.unwrap());
601
602            // Remove file
603            mock_fs
604                .remove_file(Path::new("/test/file.txt"))
605                .await
606                .unwrap();
607
608            // Verify it's gone
609            assert!(!mock_fs.exists(Path::new("/test/file.txt")).await.unwrap());
610
611            // Try to remove non-existent file - should error
612            let result = mock_fs.remove_file(Path::new("/test/file.txt")).await;
613            assert!(result.is_err());
614            assert!(result.unwrap_err().to_string().contains("File not found"));
615        }
616
617        #[tokio::test]
618        async fn test_file_system_operations_are_send_sync() {
619            fn assert_send_sync<T: Send + Sync>() {}
620
621            assert_send_sync::<DefaultFileSystem>();
622            assert_send_sync::<MockFileSystem>();
623            assert_send_sync::<Arc<dyn FileSystemOperations>>();
624        }
625    }
626}