Skip to main content

mockforge_ftp/
vfs.rs

1use anyhow::Result;
2use chrono::{DateTime, Utc};
3use handlebars::Handlebars;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::{HashMap, HashSet};
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use tokio::sync::RwLock;
10
11/// Virtual File System for FTP server
12#[derive(Debug, Clone)]
13pub struct VirtualFileSystem {
14    #[allow(dead_code)]
15    root: PathBuf,
16    files: Arc<RwLock<HashMap<PathBuf, VirtualFile>>>,
17    fixtures: Arc<RwLock<HashMap<PathBuf, FileFixture>>>,
18    directories: Arc<RwLock<HashSet<PathBuf>>>,
19}
20
21impl VirtualFileSystem {
22    pub fn new(root: PathBuf) -> Self {
23        let mut dirs = HashSet::new();
24        dirs.insert(PathBuf::from("/"));
25        Self {
26            root,
27            files: Arc::new(RwLock::new(HashMap::new())),
28            fixtures: Arc::new(RwLock::new(HashMap::new())),
29            directories: Arc::new(RwLock::new(dirs)),
30        }
31    }
32
33    pub fn add_file(&self, path: PathBuf, file: VirtualFile) -> Result<()> {
34        let mut files = self.files.blocking_write();
35        files.insert(path, file);
36        Ok(())
37    }
38
39    pub fn get_file(&self, path: &Path) -> Option<VirtualFile> {
40        let files = self.files.blocking_read();
41        if let Some(file) = files.get(path) {
42            return Some(file.clone());
43        }
44
45        // Check fixtures
46        let fixtures = self.fixtures.blocking_read();
47        if let Some(fixture) = fixtures.get(path) {
48            return Some(fixture.clone().to_virtual_file());
49        }
50
51        None
52    }
53
54    pub fn remove_file(&self, path: &Path) -> Result<()> {
55        let mut files = self.files.blocking_write();
56        files.remove(path);
57        Ok(())
58    }
59
60    pub fn list_files(&self, path: &Path) -> Vec<VirtualFile> {
61        let files = self.files.blocking_read();
62        let fixtures = self.fixtures.blocking_read();
63        collect_visible_files(path, &files, &fixtures)
64    }
65
66    pub fn clear(&self) -> Result<()> {
67        let mut files = self.files.blocking_write();
68        files.clear();
69        Ok(())
70    }
71
72    pub fn add_fixture(&self, fixture: FileFixture) -> Result<()> {
73        let mut fixtures = self.fixtures.blocking_write();
74        fixtures.insert(fixture.path.clone(), fixture);
75        Ok(())
76    }
77
78    pub fn load_fixtures(&self, fixtures: Vec<FileFixture>) -> Result<()> {
79        if fixtures.is_empty() {
80            return Ok(());
81        }
82        // Construction also runs inside async applications. blocking_write()
83        // panics there; take the uncontended initialization lock without blocking.
84        let mut stored = self.fixtures.try_write().map_err(|_| {
85            anyhow::anyhow!("Cannot load FTP fixtures while the fixture registry is in use")
86        })?;
87        for fixture in fixtures {
88            stored.insert(fixture.path.clone(), fixture);
89        }
90        Ok(())
91    }
92
93    /// Create a directory in the virtual filesystem
94    pub fn create_directory(&self, path: PathBuf) -> Result<()> {
95        let mut dirs = self.directories.blocking_write();
96        dirs.insert(path);
97        Ok(())
98    }
99
100    /// Remove a directory (must be empty)
101    pub fn remove_directory(&self, path: &Path) -> Result<()> {
102        if self.is_directory_empty(path) {
103            let mut dirs = self.directories.blocking_write();
104            dirs.remove(path);
105            Ok(())
106        } else {
107            Err(anyhow::anyhow!("Directory is not empty"))
108        }
109    }
110
111    /// Check if a directory exists
112    pub fn directory_exists(&self, path: &Path) -> bool {
113        let dirs = self.directories.blocking_read();
114        dirs.contains(path)
115    }
116
117    /// Check if a directory is empty (no files or subdirectories inside)
118    pub fn is_directory_empty(&self, path: &Path) -> bool {
119        let files = self.files.blocking_read();
120        let has_files =
121            files.keys().any(|file_path| file_path != path && file_path.starts_with(path));
122        if has_files {
123            return false;
124        }
125
126        let dirs = self.directories.blocking_read();
127        let has_subdirs =
128            dirs.iter().any(|dir_path| dir_path != path && dir_path.starts_with(path));
129        !has_subdirs
130    }
131
132    /// Async version of add_file - use this in async contexts
133    pub async fn add_file_async(&self, path: PathBuf, file: VirtualFile) -> Result<()> {
134        let mut files = self.files.write().await;
135        files.insert(path, file);
136        Ok(())
137    }
138
139    /// Async version of list_files — use from async contexts.
140    pub async fn list_files_async(&self, path: &Path) -> Vec<VirtualFile> {
141        let files = self.files.read().await;
142        let fixtures = self.fixtures.read().await;
143        collect_visible_files(path, &files, &fixtures)
144    }
145
146    /// Async version of remove_file — use from async contexts.
147    pub async fn remove_file_async(&self, path: &Path) -> Result<()> {
148        let mut files = self.files.write().await;
149        files.remove(path);
150        Ok(())
151    }
152
153    /// Async version of directory_exists — use from async contexts.
154    pub async fn directory_exists_async(&self, path: &Path) -> bool {
155        let dirs = self.directories.read().await;
156        dirs.contains(path)
157    }
158
159    /// Async version of create_directory — use from async contexts.
160    pub async fn create_directory_async(&self, path: PathBuf) -> Result<()> {
161        let mut dirs = self.directories.write().await;
162        dirs.insert(path);
163        Ok(())
164    }
165
166    /// Async version of is_directory_empty — use from async contexts.
167    pub async fn is_directory_empty_async(&self, path: &Path) -> bool {
168        let files = self.files.read().await;
169        let has_files =
170            files.keys().any(|file_path| file_path != path && file_path.starts_with(path));
171        if has_files {
172            return false;
173        }
174
175        let dirs = self.directories.read().await;
176        !dirs.iter().any(|dir_path| dir_path != path && dir_path.starts_with(path))
177    }
178
179    /// Async version of remove_directory — use from async contexts.
180    pub async fn remove_directory_async(&self, path: &Path) -> Result<()> {
181        if self.is_directory_empty_async(path).await {
182            let mut dirs = self.directories.write().await;
183            dirs.remove(path);
184            Ok(())
185        } else {
186            Err(anyhow::anyhow!("Directory is not empty"))
187        }
188    }
189
190    /// Async version of get_file - use this in async contexts
191    pub async fn get_file_async(&self, path: &Path) -> Option<VirtualFile> {
192        let files = self.files.read().await;
193        if let Some(file) = files.get(path) {
194            return Some(file.clone());
195        }
196
197        // Check fixtures
198        let fixtures = self.fixtures.read().await;
199        if let Some(fixture) = fixtures.get(path) {
200            return Some(fixture.clone().to_virtual_file());
201        }
202
203        None
204    }
205}
206
207// Keep listing consistent with get_file: runtime files override fixtures at
208// the same path, and untouched fixture files are visible to FTP LIST as well.
209fn collect_visible_files(
210    path: &Path,
211    files: &HashMap<PathBuf, VirtualFile>,
212    fixtures: &HashMap<PathBuf, FileFixture>,
213) -> Vec<VirtualFile> {
214    files
215        .iter()
216        .filter(|(file_path, _)| file_path.starts_with(path))
217        .map(|(_, file)| file.clone())
218        .chain(
219            fixtures
220                .iter()
221                .filter(|(fixture_path, _)| {
222                    fixture_path.starts_with(path) && !files.contains_key(*fixture_path)
223                })
224                .map(|(_, fixture)| fixture.clone().to_virtual_file()),
225        )
226        .collect()
227}
228
229/// Virtual file representation
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct VirtualFile {
232    pub path: PathBuf,
233    pub content: FileContent,
234    pub metadata: FileMetadata,
235    pub created_at: DateTime<Utc>,
236    pub modified_at: DateTime<Utc>,
237}
238
239impl VirtualFile {
240    pub fn new(path: PathBuf, content: FileContent, metadata: FileMetadata) -> Self {
241        let now = Utc::now();
242        Self {
243            path,
244            content,
245            metadata,
246            created_at: now,
247            modified_at: now,
248        }
249    }
250
251    pub fn render_content(&self) -> Result<Vec<u8>> {
252        match &self.content {
253            FileContent::Static(data) => Ok(data.clone()),
254            FileContent::Template(template) => {
255                // Render template using Handlebars
256                let handlebars = Handlebars::new();
257                let context = create_template_context();
258                let rendered = handlebars.render_template(template, &context)?;
259                Ok(rendered.into_bytes())
260            }
261            FileContent::Generated { size, pattern } => match pattern {
262                GenerationPattern::Random => Ok((0..*size).map(|_| rand::random::<u8>()).collect()),
263                GenerationPattern::Zeros => Ok(vec![0; *size]),
264                GenerationPattern::Ones => Ok(vec![1; *size]),
265                GenerationPattern::Incremental => Ok((0..*size).map(|i| (i % 256) as u8).collect()),
266            },
267        }
268    }
269}
270
271/// File content types
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub enum FileContent {
274    Static(Vec<u8>),
275    Template(String),
276    Generated {
277        size: usize,
278        pattern: GenerationPattern,
279    },
280}
281
282/// File metadata
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct FileMetadata {
285    pub permissions: String,
286    pub owner: String,
287    pub group: String,
288    pub size: u64,
289}
290
291impl Default for FileMetadata {
292    fn default() -> Self {
293        Self {
294            permissions: "644".to_string(),
295            owner: "mockforge".to_string(),
296            group: "users".to_string(),
297            size: 0,
298        }
299    }
300}
301
302/// Generation patterns for synthetic files
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub enum GenerationPattern {
305    Random,
306    Zeros,
307    Ones,
308    Incremental,
309}
310
311/// File fixture for configuration
312#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct FileFixture {
314    pub path: PathBuf,
315    pub content: FileContent,
316    pub metadata: FileMetadata,
317}
318
319impl FileFixture {
320    pub fn to_virtual_file(self) -> VirtualFile {
321        VirtualFile::new(self.path, self.content, self.metadata)
322    }
323}
324
325/// Create a template context with common variables for template rendering
326fn create_template_context() -> Value {
327    let mut context = serde_json::Map::new();
328
329    // Add current timestamp
330    let now = Utc::now();
331    context.insert("now".to_string(), Value::String(now.to_rfc3339()));
332    context.insert("timestamp".to_string(), Value::Number(now.timestamp().into()));
333    context.insert("date".to_string(), Value::String(now.format("%Y-%m-%d").to_string()));
334    context.insert("time".to_string(), Value::String(now.format("%H:%M:%S").to_string()));
335
336    // Add random values
337    context.insert("random_int".to_string(), Value::Number(rand::random::<i64>().into()));
338    context.insert(
339        "random_float".to_string(),
340        Value::String(format!("{:.6}", rand::random::<f64>())),
341    );
342
343    // Add UUID
344    context.insert("uuid".to_string(), Value::String(uuid::Uuid::new_v4().to_string()));
345
346    // Add some sample data
347    let mut faker = serde_json::Map::new();
348    faker.insert("name".to_string(), Value::String("John Doe".to_string()));
349    faker.insert("email".to_string(), Value::String("john.doe@example.com".to_string()));
350    faker.insert("age".to_string(), Value::Number(30.into()));
351    context.insert("faker".to_string(), Value::Object(faker));
352
353    Value::Object(context)
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn test_file_metadata_default() {
362        let metadata = FileMetadata::default();
363        assert_eq!(metadata.permissions, "644");
364        assert_eq!(metadata.owner, "mockforge");
365        assert_eq!(metadata.group, "users");
366        assert_eq!(metadata.size, 0);
367    }
368
369    #[test]
370    fn test_file_metadata_clone() {
371        let metadata = FileMetadata {
372            permissions: "755".to_string(),
373            owner: "root".to_string(),
374            group: "root".to_string(),
375            size: 1024,
376        };
377
378        let cloned = metadata.clone();
379        assert_eq!(metadata.permissions, cloned.permissions);
380        assert_eq!(metadata.owner, cloned.owner);
381        assert_eq!(metadata.size, cloned.size);
382    }
383
384    #[test]
385    fn test_generation_pattern_clone() {
386        let pattern = GenerationPattern::Random;
387        let _cloned = pattern.clone();
388        // Just verify it can be cloned
389    }
390
391    #[test]
392    fn test_generation_pattern_debug() {
393        let pattern = GenerationPattern::Zeros;
394        let debug = format!("{:?}", pattern);
395        assert!(debug.contains("Zeros"));
396    }
397
398    #[test]
399    fn test_file_content_static() {
400        let content = FileContent::Static(b"hello world".to_vec());
401        let debug = format!("{:?}", content);
402        assert!(debug.contains("Static"));
403    }
404
405    #[test]
406    fn test_file_content_template() {
407        let content = FileContent::Template("Hello {{name}}".to_string());
408        let debug = format!("{:?}", content);
409        assert!(debug.contains("Template"));
410    }
411
412    #[test]
413    fn test_file_content_generated() {
414        let content = FileContent::Generated {
415            size: 100,
416            pattern: GenerationPattern::Ones,
417        };
418        let debug = format!("{:?}", content);
419        assert!(debug.contains("Generated"));
420    }
421
422    #[test]
423    fn test_virtual_file_new() {
424        let file = VirtualFile::new(
425            PathBuf::from("/test.txt"),
426            FileContent::Static(b"content".to_vec()),
427            FileMetadata::default(),
428        );
429
430        assert_eq!(file.path, PathBuf::from("/test.txt"));
431    }
432
433    #[test]
434    fn test_virtual_file_render_static() {
435        let file = VirtualFile::new(
436            PathBuf::from("/test.txt"),
437            FileContent::Static(b"hello".to_vec()),
438            FileMetadata::default(),
439        );
440
441        let content = file.render_content().unwrap();
442        assert_eq!(content, b"hello".to_vec());
443    }
444
445    #[test]
446    fn test_virtual_file_render_generated_zeros() {
447        let file = VirtualFile::new(
448            PathBuf::from("/zeros.bin"),
449            FileContent::Generated {
450                size: 10,
451                pattern: GenerationPattern::Zeros,
452            },
453            FileMetadata::default(),
454        );
455
456        let content = file.render_content().unwrap();
457        assert_eq!(content.len(), 10);
458        assert!(content.iter().all(|&b| b == 0));
459    }
460
461    #[test]
462    fn test_virtual_file_render_generated_ones() {
463        let file = VirtualFile::new(
464            PathBuf::from("/ones.bin"),
465            FileContent::Generated {
466                size: 10,
467                pattern: GenerationPattern::Ones,
468            },
469            FileMetadata::default(),
470        );
471
472        let content = file.render_content().unwrap();
473        assert_eq!(content.len(), 10);
474        assert!(content.iter().all(|&b| b == 1));
475    }
476
477    #[test]
478    fn test_virtual_file_render_generated_incremental() {
479        let file = VirtualFile::new(
480            PathBuf::from("/inc.bin"),
481            FileContent::Generated {
482                size: 256,
483                pattern: GenerationPattern::Incremental,
484            },
485            FileMetadata::default(),
486        );
487
488        let content = file.render_content().unwrap();
489        assert_eq!(content.len(), 256);
490        for (i, &b) in content.iter().enumerate() {
491            assert_eq!(b, i as u8);
492        }
493    }
494
495    #[test]
496    fn test_virtual_file_render_generated_random() {
497        let file = VirtualFile::new(
498            PathBuf::from("/random.bin"),
499            FileContent::Generated {
500                size: 100,
501                pattern: GenerationPattern::Random,
502            },
503            FileMetadata::default(),
504        );
505
506        let content = file.render_content().unwrap();
507        assert_eq!(content.len(), 100);
508    }
509
510    #[test]
511    fn test_virtual_file_clone() {
512        let file = VirtualFile::new(
513            PathBuf::from("/test.txt"),
514            FileContent::Static(b"test".to_vec()),
515            FileMetadata::default(),
516        );
517
518        let cloned = file.clone();
519        assert_eq!(file.path, cloned.path);
520    }
521
522    #[test]
523    fn test_virtual_file_debug() {
524        let file = VirtualFile::new(
525            PathBuf::from("/test.txt"),
526            FileContent::Static(vec![]),
527            FileMetadata::default(),
528        );
529
530        let debug = format!("{:?}", file);
531        assert!(debug.contains("VirtualFile"));
532    }
533
534    #[test]
535    fn test_file_fixture_to_virtual_file() {
536        let fixture = FileFixture {
537            path: PathBuf::from("/fixture.txt"),
538            content: FileContent::Static(b"fixture content".to_vec()),
539            metadata: FileMetadata::default(),
540        };
541
542        let file = fixture.to_virtual_file();
543        assert_eq!(file.path, PathBuf::from("/fixture.txt"));
544    }
545
546    #[test]
547    fn test_file_fixture_clone() {
548        let fixture = FileFixture {
549            path: PathBuf::from("/test.txt"),
550            content: FileContent::Static(vec![]),
551            metadata: FileMetadata::default(),
552        };
553
554        let cloned = fixture.clone();
555        assert_eq!(fixture.path, cloned.path);
556    }
557
558    #[test]
559    fn test_vfs_new() {
560        let vfs = VirtualFileSystem::new(PathBuf::from("/"));
561        let files = vfs.list_files(&PathBuf::from("/"));
562        assert!(files.is_empty());
563    }
564
565    #[test]
566    fn test_vfs_add_file() {
567        let vfs = VirtualFileSystem::new(PathBuf::from("/"));
568        let file = VirtualFile::new(
569            PathBuf::from("/test.txt"),
570            FileContent::Static(b"hello".to_vec()),
571            FileMetadata::default(),
572        );
573
574        vfs.add_file(PathBuf::from("/test.txt"), file).unwrap();
575
576        let retrieved = vfs.get_file(&PathBuf::from("/test.txt"));
577        assert!(retrieved.is_some());
578    }
579
580    #[test]
581    fn test_vfs_get_file_not_found() {
582        let vfs = VirtualFileSystem::new(PathBuf::from("/"));
583        let retrieved = vfs.get_file(&PathBuf::from("/nonexistent.txt"));
584        assert!(retrieved.is_none());
585    }
586
587    #[test]
588    fn test_vfs_remove_file() {
589        let vfs = VirtualFileSystem::new(PathBuf::from("/"));
590        let file = VirtualFile::new(
591            PathBuf::from("/test.txt"),
592            FileContent::Static(vec![]),
593            FileMetadata::default(),
594        );
595
596        vfs.add_file(PathBuf::from("/test.txt"), file).unwrap();
597        vfs.remove_file(&PathBuf::from("/test.txt")).unwrap();
598
599        let retrieved = vfs.get_file(&PathBuf::from("/test.txt"));
600        assert!(retrieved.is_none());
601    }
602
603    #[test]
604    fn test_vfs_list_files() {
605        let vfs = VirtualFileSystem::new(PathBuf::from("/"));
606
607        let file1 = VirtualFile::new(
608            PathBuf::from("/dir/file1.txt"),
609            FileContent::Static(vec![]),
610            FileMetadata::default(),
611        );
612        let file2 = VirtualFile::new(
613            PathBuf::from("/dir/file2.txt"),
614            FileContent::Static(vec![]),
615            FileMetadata::default(),
616        );
617
618        vfs.add_file(PathBuf::from("/dir/file1.txt"), file1).unwrap();
619        vfs.add_file(PathBuf::from("/dir/file2.txt"), file2).unwrap();
620
621        let files = vfs.list_files(&PathBuf::from("/dir"));
622        assert_eq!(files.len(), 2);
623    }
624
625    #[test]
626    fn test_vfs_clear() {
627        let vfs = VirtualFileSystem::new(PathBuf::from("/"));
628
629        let file = VirtualFile::new(
630            PathBuf::from("/test.txt"),
631            FileContent::Static(vec![]),
632            FileMetadata::default(),
633        );
634        vfs.add_file(PathBuf::from("/test.txt"), file).unwrap();
635
636        vfs.clear().unwrap();
637
638        let files = vfs.list_files(&PathBuf::from("/"));
639        assert!(files.is_empty());
640    }
641
642    #[test]
643    fn test_vfs_add_fixture() {
644        let vfs = VirtualFileSystem::new(PathBuf::from("/"));
645        let fixture = FileFixture {
646            path: PathBuf::from("/fixture.txt"),
647            content: FileContent::Static(b"fixture".to_vec()),
648            metadata: FileMetadata::default(),
649        };
650
651        vfs.add_fixture(fixture).unwrap();
652
653        let file = vfs.get_file(&PathBuf::from("/fixture.txt"));
654        assert!(file.is_some());
655    }
656
657    #[test]
658    fn test_vfs_load_fixtures() {
659        let vfs = VirtualFileSystem::new(PathBuf::from("/"));
660        let fixtures = vec![
661            FileFixture {
662                path: PathBuf::from("/f1.txt"),
663                content: FileContent::Static(vec![]),
664                metadata: FileMetadata::default(),
665            },
666            FileFixture {
667                path: PathBuf::from("/f2.txt"),
668                content: FileContent::Static(vec![]),
669                metadata: FileMetadata::default(),
670            },
671        ];
672
673        vfs.load_fixtures(fixtures).unwrap();
674
675        assert!(vfs.get_file(&PathBuf::from("/f1.txt")).is_some());
676        assert!(vfs.get_file(&PathBuf::from("/f2.txt")).is_some());
677    }
678
679    #[test]
680    fn test_vfs_clone() {
681        let vfs = VirtualFileSystem::new(PathBuf::from("/"));
682        let _cloned = vfs.clone();
683        // Just verify it can be cloned
684    }
685
686    #[tokio::test]
687    async fn fixture_load_reports_contention_without_blocking_async_runtime() {
688        let vfs = VirtualFileSystem::new(PathBuf::from("/"));
689        let held = vfs.fixtures.write().await;
690        let result = vfs.load_fixtures(vec![FileFixture {
691            path: PathBuf::from("/new.txt"),
692            content: FileContent::Static(b"new".to_vec()),
693            metadata: FileMetadata::default(),
694        }]);
695        assert!(result.is_err());
696        assert!(held.is_empty(), "failed loads must not partially install fixtures");
697        assert!(vfs.load_fixtures(Vec::new()).is_ok(), "empty load remains a no-op");
698    }
699
700    #[test]
701    fn test_vfs_debug() {
702        let vfs = VirtualFileSystem::new(PathBuf::from("/"));
703        let debug = format!("{:?}", vfs);
704        assert!(debug.contains("VirtualFileSystem"));
705    }
706
707    #[test]
708    fn test_template_context_has_expected_fields() {
709        let context = create_template_context();
710        assert!(context.get("now").is_some());
711        assert!(context.get("timestamp").is_some());
712        assert!(context.get("date").is_some());
713        assert!(context.get("uuid").is_some());
714        assert!(context.get("faker").is_some());
715    }
716
717    #[test]
718    fn test_virtual_file_render_template() {
719        let file = VirtualFile::new(
720            PathBuf::from("/template.txt"),
721            FileContent::Template("Hello {{faker.name}}!".to_string()),
722            FileMetadata::default(),
723        );
724
725        let content = file.render_content().unwrap();
726        let text = String::from_utf8(content).unwrap();
727        assert!(text.contains("Hello"));
728        assert!(text.contains("John Doe")); // From the faker context
729    }
730
731    #[test]
732    fn test_virtual_file_render_template_with_timestamp() {
733        let file = VirtualFile::new(
734            PathBuf::from("/timestamp.txt"),
735            FileContent::Template("Current timestamp: {{timestamp}}".to_string()),
736            FileMetadata::default(),
737        );
738
739        let content = file.render_content().unwrap();
740        let text = String::from_utf8(content).unwrap();
741        assert!(text.contains("Current timestamp:"));
742    }
743
744    #[test]
745    fn test_virtual_file_render_template_with_uuid() {
746        let file = VirtualFile::new(
747            PathBuf::from("/uuid.txt"),
748            FileContent::Template("ID: {{uuid}}".to_string()),
749            FileMetadata::default(),
750        );
751
752        let content = file.render_content().unwrap();
753        let text = String::from_utf8(content).unwrap();
754        assert!(text.starts_with("ID: "));
755        // UUID should be present and not empty
756        let uuid_part = text.trim_start_matches("ID: ");
757        assert!(!uuid_part.is_empty());
758    }
759
760    #[test]
761    fn test_vfs_get_file_from_fixtures() {
762        let vfs = VirtualFileSystem::new(PathBuf::from("/"));
763        let fixture = FileFixture {
764            path: PathBuf::from("/fixture.txt"),
765            content: FileContent::Static(b"fixture content".to_vec()),
766            metadata: FileMetadata::default(),
767        };
768
769        vfs.add_fixture(fixture).unwrap();
770
771        let file = vfs.get_file(&PathBuf::from("/fixture.txt"));
772        assert!(file.is_some());
773        let content = file.unwrap().render_content().unwrap();
774        assert_eq!(content, b"fixture content");
775    }
776
777    #[test]
778    fn test_vfs_files_priority_over_fixtures() {
779        let vfs = VirtualFileSystem::new(PathBuf::from("/"));
780
781        // Add a fixture
782        let fixture = FileFixture {
783            path: PathBuf::from("/test.txt"),
784            content: FileContent::Static(b"fixture".to_vec()),
785            metadata: FileMetadata::default(),
786        };
787        vfs.add_fixture(fixture).unwrap();
788
789        // Add a regular file with same path
790        let file = VirtualFile::new(
791            PathBuf::from("/test.txt"),
792            FileContent::Static(b"file".to_vec()),
793            FileMetadata::default(),
794        );
795        vfs.add_file(PathBuf::from("/test.txt"), file).unwrap();
796
797        // Files should take priority over fixtures
798        let retrieved = vfs.get_file(&PathBuf::from("/test.txt")).unwrap();
799        let content = retrieved.render_content().unwrap();
800        assert_eq!(content, b"file");
801        let listed = vfs.list_files(Path::new("/"));
802        assert_eq!(listed.len(), 1, "an override must not duplicate its fixture in LIST");
803        assert_eq!(listed[0].render_content().unwrap(), b"file");
804    }
805
806    #[tokio::test]
807    async fn async_listing_includes_fixtures_with_runtime_file_precedence() {
808        let vfs = VirtualFileSystem::new(PathBuf::from("/"));
809        vfs.load_fixtures(
810            ["/pub/shared.txt", "/pub/fixture.txt", "/other/hidden.txt"]
811                .into_iter()
812                .map(|path| FileFixture {
813                    path: path.into(),
814                    content: FileContent::Static(b"fixture".to_vec()),
815                    metadata: FileMetadata::default(),
816                })
817                .collect(),
818        )
819        .unwrap();
820        vfs.add_file_async(
821            "/pub/shared.txt".into(),
822            VirtualFile::new(
823                "/pub/shared.txt".into(),
824                FileContent::Static(b"runtime".to_vec()),
825                FileMetadata::default(),
826            ),
827        )
828        .await
829        .unwrap();
830        let listed = vfs.list_files_async(Path::new("/pub")).await;
831        assert_eq!(listed.len(), 2);
832        let shared = listed
833            .iter()
834            .find(|file| file.path.as_path() == Path::new("/pub/shared.txt"))
835            .unwrap();
836        assert_eq!(shared.render_content().unwrap(), b"runtime");
837        let fixture = listed
838            .iter()
839            .find(|file| file.path.as_path() == Path::new("/pub/fixture.txt"))
840            .unwrap();
841        assert_eq!(fixture.render_content().unwrap(), b"fixture");
842    }
843
844    #[test]
845    fn test_vfs_list_files_empty_path() {
846        let vfs = VirtualFileSystem::new(PathBuf::from("/"));
847
848        let file1 = VirtualFile::new(
849            PathBuf::from("/file1.txt"),
850            FileContent::Static(vec![]),
851            FileMetadata::default(),
852        );
853        let file2 = VirtualFile::new(
854            PathBuf::from("/subdir/file2.txt"),
855            FileContent::Static(vec![]),
856            FileMetadata::default(),
857        );
858
859        vfs.add_file(PathBuf::from("/file1.txt"), file1).unwrap();
860        vfs.add_file(PathBuf::from("/subdir/file2.txt"), file2).unwrap();
861
862        // List all files from root
863        let files = vfs.list_files(&PathBuf::from("/"));
864        assert_eq!(files.len(), 2);
865    }
866
867    #[test]
868    fn test_virtual_file_serialization() {
869        let file = VirtualFile::new(
870            PathBuf::from("/test.txt"),
871            FileContent::Static(b"test".to_vec()),
872            FileMetadata::default(),
873        );
874
875        // Test serialization
876        let serialized = serde_json::to_string(&file);
877        assert!(serialized.is_ok());
878
879        // Test deserialization
880        let deserialized: Result<VirtualFile, _> = serde_json::from_str(&serialized.unwrap());
881        assert!(deserialized.is_ok());
882    }
883
884    #[test]
885    fn test_file_metadata_serialization() {
886        let metadata = FileMetadata {
887            permissions: "755".to_string(),
888            owner: "root".to_string(),
889            group: "admin".to_string(),
890            size: 2048,
891        };
892
893        let serialized = serde_json::to_string(&metadata);
894        assert!(serialized.is_ok());
895
896        let deserialized: Result<FileMetadata, _> = serde_json::from_str(&serialized.unwrap());
897        assert!(deserialized.is_ok());
898    }
899
900    #[test]
901    fn test_file_content_serialization() {
902        let content = FileContent::Static(b"test content".to_vec());
903        let serialized = serde_json::to_string(&content);
904        assert!(serialized.is_ok());
905    }
906
907    #[test]
908    fn test_generation_pattern_serialization() {
909        let pattern = GenerationPattern::Random;
910        let serialized = serde_json::to_string(&pattern);
911        assert!(serialized.is_ok());
912    }
913}