1use std::cmp::Reverse;
8use std::collections::HashMap;
9use std::io::{Read, Write};
10use std::path::{Path, PathBuf};
11use std::time::SystemTime;
12
13use md5::{Digest as Md5Digest, Md5};
14
15use crate::types::{FileEntry, FsError, DIR_ARCHIVE, DIR_JOURNAL, DIR_MEDIA, DIR_USER_ROOT};
16
17const FORBIDDEN_CHARS: &[(&str, &str)] = &[
19 ("<", "<"),
20 (">", ">"),
21 (":", "꞉"),
22 ("\"", "″"),
23 ("|", "⼁"),
24 ("\\", "\"),
25 ("?", "?"),
26 ("*", "﹡"),
27 ("\x00", ""),
28 ("/", "/"),
29];
30
31pub const SYSTEM_DIRS: &[&str] = &["archive", "media", "journal", "insights", "img"];
33
34pub const SYSTEM_FILES: &[&str] = &[
36 "Chat.md", "Later.md", "Done.md", "Shop.md", "Watch.md", "Read.md",
37];
38
39const IGNORED_NAMES: &[&str] = &[".", "..", ".obsidian", ".gitignore", ".DS_Store", ".git"];
41
42#[derive(Clone, Debug)]
51pub struct VirtualFs {
52 root: PathBuf,
53 quota_kb: i64,
54}
55
56impl VirtualFs {
57 pub fn new(root: PathBuf) -> std::io::Result<Self> {
61 if !root.exists() {
62 std::fs::create_dir_all(&root)?;
63 }
64 Ok(Self { root, quota_kb: 0 })
65 }
66
67 pub fn with_quota(mut self, quota_kb: i64) -> Self {
69 self.quota_kb = quota_kb;
70 self
71 }
72
73 pub fn root(&self) -> &Path {
75 &self.root
76 }
77
78 pub fn quota_kb(&self) -> i64 {
80 self.quota_kb
81 }
82
83 pub fn safe_path(&self, dir: &str, filename: &str) -> Result<PathBuf, FsError> {
89 let dir_trimmed = dir.trim();
90 if dir_trimmed.starts_with("..") {
91 return Err(FsError::UnsafePath);
92 }
93
94 let relative: PathBuf = if dir == DIR_USER_ROOT {
95 if filename.is_empty() {
96 return Ok(self.root.clone());
97 }
98 PathBuf::from(filename)
99 } else {
100 PathBuf::from(dir).join(filename)
101 };
102
103 let rel_str = relative.to_string_lossy();
104 if rel_str.starts_with('/') || rel_str.starts_with("../") {
105 return Err(FsError::UnsafePath);
106 }
107
108 let full = self.root.join(&relative);
109
110 let stripped = full
112 .strip_prefix(&self.root)
113 .map_err(|_| FsError::UnsafePath)?;
114 let (normalized, escaped) = normalize_path(stripped);
115 if escaped || normalized.to_string_lossy().contains("..") {
116 return Err(FsError::UnsafePath);
117 }
118
119 Ok(self.root.join(&normalized))
120 }
121
122 pub fn read_path(&self, path: &str) -> Result<String, FsError> {
127 let (dir, filename) = split_posix_path(path);
128 self.read(dir, filename)
129 }
130
131 pub fn write_path(&self, path: &str, content: &str) -> Result<(), FsError> {
133 let (dir, filename) = split_posix_path(path);
134 self.write(dir, filename, content)
135 }
136
137 pub fn delete_path(&self, path: &str) -> Result<(), FsError> {
139 let (dir, filename) = split_posix_path(path);
140 self.del(dir, filename)
141 }
142
143 pub fn rename_path(&self, old_path: &str, new_path: &str) -> Result<(), FsError> {
145 let (old_dir, old_filename) = split_posix_path(old_path);
146 let (new_dir, new_filename) = split_posix_path(new_path);
147 self.rename(old_dir, old_filename, new_dir, new_filename)
148 }
149
150 pub fn exists_path(&self, path: &str) -> Result<bool, FsError> {
152 let (dir, filename) = split_posix_path(path);
153 self.exists(dir, filename)
154 }
155
156 pub fn mtime_path(&self, path: &str) -> Result<i64, FsError> {
158 let (dir, filename) = split_posix_path(path);
159 self.mtime(dir, filename)
160 }
161
162 pub fn exists(&self, dir: &str, filename: &str) -> Result<bool, FsError> {
166 let path = self.safe_path(dir, filename)?;
167 Ok(path.exists())
168 }
169
170 pub fn read(&self, dir: &str, filename: &str) -> Result<String, FsError> {
172 let path = self.safe_path(dir, filename)?;
173 let mut file = std::fs::File::open(&path)?;
174 let mut contents = String::new();
175 file.read_to_string(&mut contents)?;
176 Ok(contents)
177 }
178
179 pub fn write(&self, dir: &str, filename: &str, content: &str) -> Result<(), FsError> {
181 let path = self.safe_path(dir, filename)?;
182
183 if let Some(parent) = path.parent() {
184 std::fs::create_dir_all(parent)?;
185 }
186
187 if self.quota_kb > 0 {
188 let new_size = content.len() as i64;
189 let old_size = std::fs::metadata(&path)
190 .map(|m| m.len() as i64)
191 .unwrap_or(0);
192 let used = self.calculate_used_quota()?;
193 let available = (self.quota_kb * 1024) - used;
194 if (new_size - old_size) > available {
195 return Err(FsError::QuotaExceeded);
196 }
197 }
198
199 let mut file = std::fs::File::create(&path)?;
200 file.write_all(content.as_bytes())?;
201 Ok(())
202 }
203
204 pub fn read_bytes(&self, dir: &str, filename: &str) -> Result<Vec<u8>, FsError> {
206 let path = self.safe_path(dir, filename)?;
207 Ok(std::fs::read(&path)?)
208 }
209
210 pub fn write_bytes(&self, dir: &str, filename: &str, data: &[u8]) -> Result<(), FsError> {
213 let path = self.safe_path(dir, filename)?;
214
215 if let Some(parent) = path.parent() {
216 std::fs::create_dir_all(parent)?;
217 }
218
219 if self.quota_kb > 0 {
220 let new_size = data.len() as i64;
221 let old_size = std::fs::metadata(&path)
222 .map(|m| m.len() as i64)
223 .unwrap_or(0);
224 let used = self.calculate_used_quota()?;
225 let available = (self.quota_kb * 1024) - used;
226 if (new_size - old_size) > available {
227 return Err(FsError::QuotaExceeded);
228 }
229 }
230
231 std::fs::write(&path, data)?;
232 Ok(())
233 }
234
235 pub fn read_path_bytes(&self, path: &str) -> Result<Vec<u8>, FsError> {
237 let (dir, filename) = split_posix_path(path);
238 self.read_bytes(dir, filename)
239 }
240
241 pub fn write_path_bytes(&self, path: &str, data: &[u8]) -> Result<(), FsError> {
243 let (dir, filename) = split_posix_path(path);
244 self.write_bytes(dir, filename, data)
245 }
246
247 pub fn del(&self, dir: &str, filename: &str) -> Result<(), FsError> {
249 let path = self.safe_path(dir, filename)?;
250 std::fs::remove_file(&path)?;
251 Ok(())
252 }
253
254 pub fn rename(
256 &self,
257 old_dir: &str,
258 old_filename: &str,
259 new_dir: &str,
260 new_filename: &str,
261 ) -> Result<(), FsError> {
262 let old_path = self.safe_path(old_dir, old_filename)?;
263 let new_path = self.safe_path(new_dir, new_filename)?;
264 if let Some(parent) = new_path.parent() {
265 std::fs::create_dir_all(parent)?;
266 }
267 std::fs::rename(&old_path, &new_path)?;
268 Ok(())
269 }
270
271 pub fn make_dir(&self, dir: &str) -> Result<(), FsError> {
273 let path = self.safe_path(dir, "")?;
274 std::fs::create_dir_all(&path)?;
275 Ok(())
276 }
277
278 pub fn touch(&self, dir: &str, filename: &str) -> Result<(), FsError> {
280 let path = self.safe_path(dir, filename)?;
281 if path.exists() {
282 let now = SystemTime::now();
283 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(now))?;
284 } else {
285 self.write(dir, filename, "")?;
286 }
287 Ok(())
288 }
289
290 pub fn ctime(&self, dir: &str, filename: &str) -> Result<i64, FsError> {
294 let path = self.safe_path(dir, filename)?;
295 let meta = std::fs::metadata(&path)?;
296 Ok(mtime_to_ms(meta.modified()?))
297 }
298
299 pub fn mtime(&self, dir: &str, filename: &str) -> Result<i64, FsError> {
301 let path = self.safe_path(dir, filename)?;
302 let meta = std::fs::metadata(&path)?;
303 Ok(mtime_to_ms(meta.modified()?))
304 }
305
306 pub fn mtimes(&self, root: &str, extensions: &[&str]) -> Result<HashMap<String, i64>, FsError> {
308 let root_path = self.safe_path(root, "")?;
309 let mut result = HashMap::new();
310 self.walk_dir(&root_path, &root_path, extensions, &mut result)?;
311 Ok(result)
312 }
313
314 pub fn files_and_dirs(&self, dir: &str) -> Result<Vec<FileEntry>, FsError> {
318 let user_path = self.safe_path(dir, "")?;
319 if !user_path.exists() {
320 return Ok(vec![]);
321 }
322
323 let mut entries = Vec::new();
324 for entry in std::fs::read_dir(&user_path)? {
325 let entry = entry?;
326 let path = entry.path();
327 let name = path
328 .file_name()
329 .and_then(|n| n.to_str())
330 .unwrap_or("")
331 .to_string();
332
333 if IGNORED_NAMES.contains(&name.as_str()) {
334 continue;
335 }
336
337 let meta = std::fs::metadata(&path)?;
338 let is_dir = meta.is_dir();
339 let ctime = mtime_to_ms(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH));
340 let hash = hash_filename(&name);
341 let display_name = display_name(&name);
342 let has_content = !is_dir && meta.len() > 0;
343
344 entries.push(FileEntry::new(
345 name,
346 hash,
347 display_name,
348 ctime,
349 has_content,
350 is_dir,
351 dir.to_string(),
352 ));
353 }
354 Ok(entries)
355 }
356
357 pub fn dirs(&self) -> Result<Vec<FileEntry>, FsError> {
359 Ok(self
360 .files_and_dirs(DIR_USER_ROOT)?
361 .into_iter()
362 .filter(|f| f.is_dir)
363 .collect())
364 }
365
366 pub fn is_multiline(&self, dir: &str, filename: &str) -> Result<bool, FsError> {
368 let content = self.read(dir, filename)?;
369 Ok(!content.trim().is_empty())
370 }
371
372 pub fn create_system_dirs(&self) -> Result<(), FsError> {
374 for dir in [DIR_ARCHIVE, DIR_MEDIA, DIR_JOURNAL] {
375 self.make_dir(dir)?;
376 }
377 Ok(())
378 }
379
380 pub fn unhash(&self, dir: &str, filename_hash: &str) -> Result<String, FsError> {
382 if dir == DIR_USER_ROOT && filename_hash == DIR_USER_ROOT {
383 return Ok(DIR_USER_ROOT.to_string());
384 }
385 let files = self.files_and_dirs(dir)?;
386 for file in &files {
387 if hash_filename(&file.name).starts_with(filename_hash) {
388 return Ok(file.name.clone());
389 }
390 }
391 for file in &files {
392 if file.name.starts_with(filename_hash) {
393 return Ok(file.name.clone());
394 }
395 }
396 Err(FsError::CannotUnhash)
397 }
398
399 pub fn search_files_by_name(&self, query: &str) -> Result<Vec<FileEntry>, FsError> {
401 let query_lower = query.to_lowercase().trim().to_string();
402 if query_lower.contains('/') {
403 return Err(FsError::UnsafePath);
404 }
405
406 let mut notes = Vec::new();
407 self.collect_md_files(&self.root, &self.root, &mut notes)?;
408
409 if !query_lower.is_empty() {
410 let matching: Vec<FileEntry> = notes
411 .iter()
412 .filter(|f| {
413 let top = f.parent_dir.split('/').next().unwrap_or("");
414 top.to_lowercase().starts_with(&query_lower)
415 || f.display_name.to_lowercase().contains(&query_lower)
416 })
417 .cloned()
418 .collect();
419 if !matching.is_empty() {
420 notes = matching;
421 }
422 }
423
424 notes.sort_by_key(|a| Reverse(a.ctime));
425 Ok(notes)
426 }
427
428 #[allow(clippy::only_used_in_recursion)]
431 fn walk_dir(
432 &self,
433 root_path: &Path,
434 current_path: &Path,
435 extensions: &[&str],
436 result: &mut HashMap<String, i64>,
437 ) -> Result<(), FsError> {
438 if !current_path.is_dir() {
439 return Ok(());
440 }
441 for entry in std::fs::read_dir(current_path)? {
442 let entry = entry?;
443 let path = entry.path();
444 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
445
446 if filename.starts_with('.') {
447 continue;
448 }
449
450 if path.is_dir() {
451 self.walk_dir(root_path, &path, extensions, result)?;
452 } else {
453 if !extensions.is_empty() {
454 let ext = path
455 .extension()
456 .and_then(|e| e.to_str())
457 .map(|e| format!(".{e}"));
458 let ext_match = ext
459 .as_ref()
460 .map(|e| extensions.contains(&e.as_str()))
461 .unwrap_or(false);
462 if !ext_match {
463 continue;
464 }
465 }
466
467 let rel = path
468 .strip_prefix(root_path)
469 .map_err(|_| FsError::UnsafePath)?;
470 let display = rel.to_string_lossy();
471 let display_path = if display.starts_with('/') || display.starts_with('\\') {
472 display[1..].to_string()
473 } else {
474 display.to_string()
475 };
476
477 let meta = std::fs::metadata(&path)?;
478 result.insert(display_path, mtime_to_ms(meta.modified()?));
479 }
480 }
481 Ok(())
482 }
483
484 #[allow(clippy::only_used_in_recursion)]
485 fn collect_md_files(
486 &self,
487 root_path: &Path,
488 current_path: &Path,
489 files: &mut Vec<FileEntry>,
490 ) -> Result<(), FsError> {
491 if !current_path.is_dir() {
492 return Ok(());
493 }
494 for entry in std::fs::read_dir(current_path)? {
495 let entry = entry?;
496 let path = entry.path();
497 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
498
499 if path.is_dir() {
500 if filename.starts_with('.') {
501 continue;
502 }
503 self.collect_md_files(root_path, &path, files)?;
504 } else {
505 if !filename.ends_with(".md") || filename.starts_with('.') {
506 continue;
507 }
508
509 let meta = std::fs::metadata(&path)?;
510 let rel = path
511 .strip_prefix(root_path)
512 .map_err(|_| FsError::UnsafePath)?;
513 let parent = rel
514 .parent()
515 .map(|p| p.to_string_lossy().to_string())
516 .unwrap_or_default();
517 let parent_str = if parent.is_empty() || parent == "." {
518 DIR_USER_ROOT.to_string()
519 } else {
520 parent
521 };
522
523 let ctime = mtime_to_ms(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH));
524 let hash = hash_filename(filename);
525 let display_name = display_name(filename);
526
527 files.push(FileEntry::new(
528 filename.to_string(),
529 hash,
530 display_name,
531 ctime,
532 meta.len() > 0,
533 false,
534 parent_str,
535 ));
536 }
537 }
538 Ok(())
539 }
540
541 fn calculate_used_quota(&self) -> std::io::Result<i64> {
542 let mut total = 0i64;
543 if self.root.exists() {
544 for entry in std::fs::read_dir(&self.root)? {
545 let entry = entry?;
546 let meta = entry.metadata()?;
547 if meta.is_file() {
548 total += meta.len() as i64;
549 } else if meta.is_dir() {
550 total += dir_size(entry.path())?;
551 }
552 }
553 }
554 Ok(total)
555 }
556}
557
558pub fn hash_filename(filename: &str) -> String {
564 let mut hasher = Md5::new();
565 hasher.update(filename.as_bytes());
566 hex::encode(hasher.finalize())[..11].to_string()
567}
568
569pub fn short_hash(filename: &str) -> String {
571 let mut hasher = Md5::new();
572 hasher.update(filename.as_bytes());
573 hex::encode(hasher.finalize())[..5].to_string()
574}
575
576pub fn sanitize_filename(filename: &str) -> String {
578 let mut result = filename.to_string();
579 for (forbidden, safe) in FORBIDDEN_CHARS {
580 result = result.replace(forbidden, safe);
581 }
582 result
583}
584
585pub fn unsanitize_filename(filename: &str) -> String {
587 let mut result = filename.to_string();
588 for (forbidden, safe) in FORBIDDEN_CHARS {
589 if !forbidden.is_empty() && *forbidden != "\x00" {
590 result = result.replace(safe, forbidden);
591 }
592 }
593 result
594}
595
596pub fn display_name(filename: &str) -> String {
598 let trimmed = filename.trim();
599 let without_ext = trimmed.strip_suffix(".md").unwrap_or(trimmed);
600 let mut chars = without_ext.chars();
601 match chars.next() {
602 None => String::new(),
603 Some(first) => first.to_uppercase().chain(chars).collect(),
604 }
605}
606
607pub fn is_checklist_item(filename: &str) -> bool {
609 let trimmed = filename.trim();
610 if !trimmed.starts_with('-') {
611 return false;
612 }
613 if let Some(pos) = trimmed.rfind('-') {
614 pos > 0 && pos < trimmed.len() - 1
615 } else {
616 false
617 }
618}
619
620pub fn exclude_checklists(files: &[FileEntry]) -> Vec<FileEntry> {
622 files
623 .iter()
624 .filter(|f| {
625 let name = f.name.trim_end_matches(".md");
626 !(name.starts_with('_') && name.ends_with('_'))
627 })
628 .cloned()
629 .collect()
630}
631
632pub fn exclude_system_dirs(files: &[FileEntry]) -> Vec<FileEntry> {
634 files
635 .iter()
636 .filter(|f| !SYSTEM_DIRS.contains(&f.name.as_str()))
637 .cloned()
638 .collect()
639}
640
641pub fn exclude_system_files(files: &[FileEntry]) -> Vec<FileEntry> {
643 files
644 .iter()
645 .filter(|f| !SYSTEM_FILES.contains(&f.name.as_str()))
646 .cloned()
647 .collect()
648}
649
650pub fn only_dirs(files: &[FileEntry]) -> Vec<FileEntry> {
652 files.iter().filter(|f| f.is_dir).cloned().collect()
653}
654
655pub fn only_files(files: &[FileEntry]) -> Vec<FileEntry> {
657 files.iter().filter(|f| !f.is_dir).cloned().collect()
658}
659
660pub fn only_user_md_files(files: &[FileEntry]) -> Vec<FileEntry> {
662 files
663 .iter()
664 .filter(|f| {
665 !f.is_dir && f.name.ends_with(".md") && !SYSTEM_FILES.contains(&f.name.as_str())
666 })
667 .cloned()
668 .collect()
669}
670
671pub fn sort_by_ctime_desc(files: &mut [FileEntry]) {
673 files.sort_by_key(|a| Reverse(a.ctime));
674}
675
676pub fn only_filenames(files: &[FileEntry]) -> Vec<String> {
678 files.iter().map(|f| f.name.clone()).collect()
679}
680
681pub fn split_posix_path(path: &str) -> (&str, &str) {
684 let path = path.trim_start_matches('/');
685 if let Some(slash_pos) = path.rfind('/') {
686 let (dir, file) = path.split_at(slash_pos);
687 (dir, &file[1..])
688 } else {
689 (crate::types::DIR_USER_ROOT, path)
690 }
691}
692
693fn normalize_path(path: &Path) -> (PathBuf, bool) {
696 let mut components = Vec::new();
697 let mut escaped = false;
698 for component in path.components() {
699 match component {
700 std::path::Component::Normal(s) => components.push(s),
701 std::path::Component::ParentDir => {
702 if components.is_empty() {
703 escaped = true;
704 } else {
705 components.pop();
706 }
707 }
708 std::path::Component::CurDir => {}
709 std::path::Component::RootDir | std::path::Component::Prefix(_) => {}
710 }
711 }
712 (components.iter().collect(), escaped)
713}
714
715fn mtime_to_ms(time: SystemTime) -> i64 {
716 time.duration_since(SystemTime::UNIX_EPOCH)
717 .map(|d| d.as_millis() as i64)
718 .unwrap_or(0)
719}
720
721fn dir_size(path: PathBuf) -> std::io::Result<i64> {
722 let mut total = 0i64;
723 for entry in std::fs::read_dir(path)? {
724 let entry = entry?;
725 let meta = entry.metadata()?;
726 if meta.is_file() {
727 total += meta.len() as i64;
728 } else if meta.is_dir() {
729 total += dir_size(entry.path())?;
730 }
731 }
732 Ok(total)
733}
734
735#[cfg(test)]
740mod tests {
741 use super::*;
742 use tempfile::TempDir;
743
744 fn test_fs() -> (VirtualFs, TempDir) {
745 let dir = TempDir::new().unwrap();
746 let fs = VirtualFs::new(dir.path().to_path_buf()).unwrap();
747 (fs, dir)
748 }
749
750 #[test]
751 fn test_write_and_read() {
752 let (fs, _t) = test_fs();
753 fs.write("brain", "test.md", "Hello").unwrap();
754 assert_eq!(fs.read("brain", "test.md").unwrap(), "Hello");
755 }
756
757 #[test]
758 fn test_exists() {
759 let (fs, _t) = test_fs();
760 assert!(!fs.exists("/", "nope.md").unwrap());
761 fs.write("/", "exists.md", "x").unwrap();
762 assert!(fs.exists("/", "exists.md").unwrap());
763 }
764
765 #[test]
766 fn test_delete() {
767 let (fs, _t) = test_fs();
768 fs.write("/", "del.md", "x").unwrap();
769 fs.del("/", "del.md").unwrap();
770 assert!(!fs.exists("/", "del.md").unwrap());
771 }
772
773 #[test]
774 fn test_rename() {
775 let (fs, _t) = test_fs();
776 fs.write("/", "old.md", "data").unwrap();
777 fs.rename("/", "old.md", "/", "new.md").unwrap();
778 assert!(!fs.exists("/", "old.md").unwrap());
779 assert_eq!(fs.read("/", "new.md").unwrap(), "data");
780 }
781
782 #[test]
783 fn test_path_traversal_rejected() {
784 let (fs, _t) = test_fs();
785 assert!(fs.safe_path("../etc", "passwd").is_err());
786 assert!(fs.safe_path("a", "../../etc/passwd").is_err());
787 }
788
789 #[test]
790 fn test_touch_creates_file() {
791 let (fs, _t) = test_fs();
792 fs.touch("/", "new.md").unwrap();
793 assert!(fs.exists("/", "new.md").unwrap());
794 }
795
796 #[test]
797 fn test_hash_filename_deterministic() {
798 assert_eq!(hash_filename("test.md"), hash_filename("test.md"));
799 assert_eq!(hash_filename("test.md").len(), 11);
800 }
801
802 #[test]
803 fn test_display_name() {
804 assert_eq!(display_name("rust.md"), "Rust");
805 assert_eq!(display_name(" filename "), "Filename");
806 }
807
808 #[test]
809 fn test_sanitize_roundtrip() {
810 let original = "test/file:name";
811 let sanitized = sanitize_filename(original);
812 assert_ne!(sanitized, original);
813 assert_eq!(unsanitize_filename(&sanitized), original);
814 }
815
816 #[test]
817 fn test_files_and_dirs() {
818 let (fs, _t) = test_fs();
819 fs.make_dir("brain").unwrap();
820 fs.write("brain", "Rust.md", "content").unwrap();
821 let entries = fs.files_and_dirs("brain").unwrap();
822 assert_eq!(entries.len(), 1);
823 assert_eq!(entries[0].name, "Rust.md");
824 }
825
826 #[test]
827 fn test_create_system_dirs() {
828 let (fs, _t) = test_fs();
829 fs.create_system_dirs().unwrap();
830 assert!(fs.exists(DIR_ARCHIVE, "").unwrap());
831 assert!(fs.exists(DIR_MEDIA, "").unwrap());
832 assert!(fs.exists(DIR_JOURNAL, "").unwrap());
833 }
834
835 #[test]
836 fn test_mtimes() {
837 let (fs, _t) = test_fs();
838 fs.write("/", "a.md", "a").unwrap();
839 let mtimes = fs.mtimes("/", &[".md"]).unwrap();
840 assert!(mtimes.contains_key("a.md"));
841 }
842
843 #[test]
844 fn test_search_files_by_name() {
845 let (fs, _t) = test_fs();
846 fs.make_dir("brain").unwrap();
847 fs.write("brain", "Rust.md", "").unwrap();
848 let results = fs.search_files_by_name("brain").unwrap();
849 assert_eq!(results.len(), 1);
850 }
851
852 #[test]
853 fn test_unhash() {
854 let (fs, _t) = test_fs();
855 fs.write("/", "target.md", "x").unwrap();
856 let h = hash_filename("target.md");
857 assert_eq!(fs.unhash("/", &h).unwrap(), "target.md");
858 }
859
860 #[test]
861 fn test_filter_functions() {
862 let f = FileEntry::new(
863 "a.md".into(),
864 "h".into(),
865 "A".into(),
866 0,
867 true,
868 false,
869 "/".into(),
870 );
871 let d = FileEntry::new(
872 "dir".into(),
873 "h".into(),
874 "Dir".into(),
875 0,
876 false,
877 true,
878 "/".into(),
879 );
880 assert_eq!(only_dirs(&[f.clone(), d.clone()]).len(), 1);
881 assert_eq!(only_files(&[f.clone(), d]).len(), 1);
882 }
883
884 #[test]
885 fn test_quota_enforcement() {
886 let dir = TempDir::new().unwrap();
887 let fs = VirtualFs::new(dir.path().to_path_buf())
888 .unwrap()
889 .with_quota(1); assert!(fs.write("/", "big.md", &"x".repeat(2048)).is_err());
891 }
892
893 #[test]
894 fn test_read_write_bytes() {
895 let (fs, _t) = test_fs();
896 let data: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A]; fs.write_bytes("media", "image.png", data).unwrap();
898 let read_back = fs.read_bytes("media", "image.png").unwrap();
899 assert_eq!(read_back, data);
900 }
901
902 #[test]
903 fn test_write_bytes_quota() {
904 let dir = TempDir::new().unwrap();
905 let fs = VirtualFs::new(dir.path().to_path_buf())
906 .unwrap()
907 .with_quota(1); let big = vec![0u8; 2048];
909 assert!(fs.write_bytes("/", "big.bin", &big).is_err());
910 }
911
912 #[test]
913 fn test_path_bytes_roundtrip() {
914 let (fs, _t) = test_fs();
915 let data = b"\x00\x01\x02\xFF binary data";
916 fs.write_path_bytes("sub/file.bin", data).unwrap();
917 let read_back = fs.read_path_bytes("sub/file.bin").unwrap();
918 assert_eq!(read_back, data);
919 }
920}