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 fn walk_dir(
431 &self,
432 root_path: &Path,
433 current_path: &Path,
434 extensions: &[&str],
435 result: &mut HashMap<String, i64>,
436 ) -> Result<(), FsError> {
437 if !current_path.is_dir() {
438 return Ok(());
439 }
440 for entry in std::fs::read_dir(current_path)? {
441 let entry = entry?;
442 let path = entry.path();
443 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
444
445 if filename.starts_with('.') {
446 continue;
447 }
448
449 if path.is_dir() {
450 self.walk_dir(root_path, &path, extensions, result)?;
451 } else {
452 if !extensions.is_empty() {
453 let ext = path
454 .extension()
455 .and_then(|e| e.to_str())
456 .map(|e| format!(".{}", e));
457 let ext_match = ext
458 .as_ref()
459 .map(|e| extensions.contains(&e.as_str()))
460 .unwrap_or(false);
461 if !ext_match {
462 continue;
463 }
464 }
465
466 let rel = path
467 .strip_prefix(root_path)
468 .map_err(|_| FsError::UnsafePath)?;
469 let display = rel.to_string_lossy();
470 let display_path = if display.starts_with('/') || display.starts_with('\\') {
471 display[1..].to_string()
472 } else {
473 display.to_string()
474 };
475
476 let meta = std::fs::metadata(&path)?;
477 result.insert(display_path, mtime_to_ms(meta.modified()?));
478 }
479 }
480 Ok(())
481 }
482
483 fn collect_md_files(
484 &self,
485 root_path: &Path,
486 current_path: &Path,
487 files: &mut Vec<FileEntry>,
488 ) -> Result<(), FsError> {
489 if !current_path.is_dir() {
490 return Ok(());
491 }
492 for entry in std::fs::read_dir(current_path)? {
493 let entry = entry?;
494 let path = entry.path();
495 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
496
497 if path.is_dir() {
498 if filename.starts_with('.') {
499 continue;
500 }
501 self.collect_md_files(root_path, &path, files)?;
502 } else {
503 if !filename.ends_with(".md") || filename.starts_with('.') {
504 continue;
505 }
506
507 let meta = std::fs::metadata(&path)?;
508 let rel = path
509 .strip_prefix(root_path)
510 .map_err(|_| FsError::UnsafePath)?;
511 let parent = rel
512 .parent()
513 .map(|p| p.to_string_lossy().to_string())
514 .unwrap_or_default();
515 let parent_str = if parent.is_empty() || parent == "." {
516 DIR_USER_ROOT.to_string()
517 } else {
518 parent
519 };
520
521 let ctime = mtime_to_ms(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH));
522 let hash = hash_filename(filename);
523 let display_name = display_name(filename);
524
525 files.push(FileEntry::new(
526 filename.to_string(),
527 hash,
528 display_name,
529 ctime,
530 meta.len() > 0,
531 false,
532 parent_str,
533 ));
534 }
535 }
536 Ok(())
537 }
538
539 fn calculate_used_quota(&self) -> std::io::Result<i64> {
540 let mut total = 0i64;
541 if self.root.exists() {
542 for entry in std::fs::read_dir(&self.root)? {
543 let entry = entry?;
544 let meta = entry.metadata()?;
545 if meta.is_file() {
546 total += meta.len() as i64;
547 } else if meta.is_dir() {
548 total += dir_size(entry.path())?;
549 }
550 }
551 }
552 Ok(total)
553 }
554}
555
556pub fn hash_filename(filename: &str) -> String {
562 let mut hasher = Md5::new();
563 hasher.update(filename.as_bytes());
564 hex::encode(hasher.finalize())[..11].to_string()
565}
566
567pub fn short_hash(filename: &str) -> String {
569 let mut hasher = Md5::new();
570 hasher.update(filename.as_bytes());
571 hex::encode(hasher.finalize())[..5].to_string()
572}
573
574pub fn sanitize_filename(filename: &str) -> String {
576 let mut result = filename.to_string();
577 for (forbidden, safe) in FORBIDDEN_CHARS {
578 result = result.replace(forbidden, safe);
579 }
580 result
581}
582
583pub fn unsanitize_filename(filename: &str) -> String {
585 let mut result = filename.to_string();
586 for (forbidden, safe) in FORBIDDEN_CHARS {
587 if !forbidden.is_empty() && *forbidden != "\x00" {
588 result = result.replace(safe, forbidden);
589 }
590 }
591 result
592}
593
594pub fn display_name(filename: &str) -> String {
596 let trimmed = filename.trim();
597 let without_ext = trimmed.strip_suffix(".md").unwrap_or(trimmed);
598 let mut chars = without_ext.chars();
599 match chars.next() {
600 None => String::new(),
601 Some(first) => first.to_uppercase().chain(chars).collect(),
602 }
603}
604
605pub fn is_checklist_item(filename: &str) -> bool {
607 let trimmed = filename.trim();
608 if !trimmed.starts_with('-') {
609 return false;
610 }
611 if let Some(pos) = trimmed.rfind('-') {
612 pos > 0 && pos < trimmed.len() - 1
613 } else {
614 false
615 }
616}
617
618pub fn exclude_checklists(files: &[FileEntry]) -> Vec<FileEntry> {
620 files
621 .iter()
622 .filter(|f| {
623 let name = f.name.trim_end_matches(".md");
624 !(name.starts_with('_') && name.ends_with('_'))
625 })
626 .cloned()
627 .collect()
628}
629
630pub fn exclude_system_dirs(files: &[FileEntry]) -> Vec<FileEntry> {
632 files
633 .iter()
634 .filter(|f| !SYSTEM_DIRS.contains(&f.name.as_str()))
635 .cloned()
636 .collect()
637}
638
639pub fn exclude_system_files(files: &[FileEntry]) -> Vec<FileEntry> {
641 files
642 .iter()
643 .filter(|f| !SYSTEM_FILES.contains(&f.name.as_str()))
644 .cloned()
645 .collect()
646}
647
648pub fn only_dirs(files: &[FileEntry]) -> Vec<FileEntry> {
650 files.iter().filter(|f| f.is_dir).cloned().collect()
651}
652
653pub fn only_files(files: &[FileEntry]) -> Vec<FileEntry> {
655 files.iter().filter(|f| !f.is_dir).cloned().collect()
656}
657
658pub fn only_user_md_files(files: &[FileEntry]) -> Vec<FileEntry> {
660 files
661 .iter()
662 .filter(|f| {
663 !f.is_dir && f.name.ends_with(".md") && !SYSTEM_FILES.contains(&f.name.as_str())
664 })
665 .cloned()
666 .collect()
667}
668
669pub fn sort_by_ctime_desc(files: &mut [FileEntry]) {
671 files.sort_by_key(|a| Reverse(a.ctime));
672}
673
674pub fn only_filenames(files: &[FileEntry]) -> Vec<String> {
676 files.iter().map(|f| f.name.clone()).collect()
677}
678
679pub fn split_posix_path(path: &str) -> (&str, &str) {
682 let path = path.trim_start_matches('/');
683 if let Some(slash_pos) = path.rfind('/') {
684 let (dir, file) = path.split_at(slash_pos);
685 (dir, &file[1..])
686 } else {
687 (crate::types::DIR_USER_ROOT, path)
688 }
689}
690
691fn normalize_path(path: &Path) -> (PathBuf, bool) {
694 let mut components = Vec::new();
695 let mut escaped = false;
696 for component in path.components() {
697 match component {
698 std::path::Component::Normal(s) => components.push(s),
699 std::path::Component::ParentDir => {
700 if components.is_empty() {
701 escaped = true;
702 } else {
703 components.pop();
704 }
705 }
706 std::path::Component::CurDir => {}
707 std::path::Component::RootDir | std::path::Component::Prefix(_) => {}
708 }
709 }
710 (components.iter().collect(), escaped)
711}
712
713fn mtime_to_ms(time: SystemTime) -> i64 {
714 time.duration_since(SystemTime::UNIX_EPOCH)
715 .map(|d| d.as_millis() as i64)
716 .unwrap_or(0)
717}
718
719fn dir_size(path: PathBuf) -> std::io::Result<i64> {
720 let mut total = 0i64;
721 for entry in std::fs::read_dir(path)? {
722 let entry = entry?;
723 let meta = entry.metadata()?;
724 if meta.is_file() {
725 total += meta.len() as i64;
726 } else if meta.is_dir() {
727 total += dir_size(entry.path())?;
728 }
729 }
730 Ok(total)
731}
732
733#[cfg(test)]
738mod tests {
739 use super::*;
740 use tempfile::TempDir;
741
742 fn test_fs() -> (VirtualFs, TempDir) {
743 let dir = TempDir::new().unwrap();
744 let fs = VirtualFs::new(dir.path().to_path_buf()).unwrap();
745 (fs, dir)
746 }
747
748 #[test]
749 fn test_write_and_read() {
750 let (fs, _t) = test_fs();
751 fs.write("brain", "test.md", "Hello").unwrap();
752 assert_eq!(fs.read("brain", "test.md").unwrap(), "Hello");
753 }
754
755 #[test]
756 fn test_exists() {
757 let (fs, _t) = test_fs();
758 assert!(!fs.exists("/", "nope.md").unwrap());
759 fs.write("/", "exists.md", "x").unwrap();
760 assert!(fs.exists("/", "exists.md").unwrap());
761 }
762
763 #[test]
764 fn test_delete() {
765 let (fs, _t) = test_fs();
766 fs.write("/", "del.md", "x").unwrap();
767 fs.del("/", "del.md").unwrap();
768 assert!(!fs.exists("/", "del.md").unwrap());
769 }
770
771 #[test]
772 fn test_rename() {
773 let (fs, _t) = test_fs();
774 fs.write("/", "old.md", "data").unwrap();
775 fs.rename("/", "old.md", "/", "new.md").unwrap();
776 assert!(!fs.exists("/", "old.md").unwrap());
777 assert_eq!(fs.read("/", "new.md").unwrap(), "data");
778 }
779
780 #[test]
781 fn test_path_traversal_rejected() {
782 let (fs, _t) = test_fs();
783 assert!(fs.safe_path("../etc", "passwd").is_err());
784 assert!(fs.safe_path("a", "../../etc/passwd").is_err());
785 }
786
787 #[test]
788 fn test_touch_creates_file() {
789 let (fs, _t) = test_fs();
790 fs.touch("/", "new.md").unwrap();
791 assert!(fs.exists("/", "new.md").unwrap());
792 }
793
794 #[test]
795 fn test_hash_filename_deterministic() {
796 assert_eq!(hash_filename("test.md"), hash_filename("test.md"));
797 assert_eq!(hash_filename("test.md").len(), 11);
798 }
799
800 #[test]
801 fn test_display_name() {
802 assert_eq!(display_name("rust.md"), "Rust");
803 assert_eq!(display_name(" filename "), "Filename");
804 }
805
806 #[test]
807 fn test_sanitize_roundtrip() {
808 let original = "test/file:name";
809 let sanitized = sanitize_filename(original);
810 assert_ne!(sanitized, original);
811 assert_eq!(unsanitize_filename(&sanitized), original);
812 }
813
814 #[test]
815 fn test_files_and_dirs() {
816 let (fs, _t) = test_fs();
817 fs.make_dir("brain").unwrap();
818 fs.write("brain", "Rust.md", "content").unwrap();
819 let entries = fs.files_and_dirs("brain").unwrap();
820 assert_eq!(entries.len(), 1);
821 assert_eq!(entries[0].name, "Rust.md");
822 }
823
824 #[test]
825 fn test_create_system_dirs() {
826 let (fs, _t) = test_fs();
827 fs.create_system_dirs().unwrap();
828 assert!(fs.exists(DIR_ARCHIVE, "").unwrap());
829 assert!(fs.exists(DIR_MEDIA, "").unwrap());
830 assert!(fs.exists(DIR_JOURNAL, "").unwrap());
831 }
832
833 #[test]
834 fn test_mtimes() {
835 let (fs, _t) = test_fs();
836 fs.write("/", "a.md", "a").unwrap();
837 let mtimes = fs.mtimes("/", &[".md"]).unwrap();
838 assert!(mtimes.contains_key("a.md"));
839 }
840
841 #[test]
842 fn test_search_files_by_name() {
843 let (fs, _t) = test_fs();
844 fs.make_dir("brain").unwrap();
845 fs.write("brain", "Rust.md", "").unwrap();
846 let results = fs.search_files_by_name("brain").unwrap();
847 assert_eq!(results.len(), 1);
848 }
849
850 #[test]
851 fn test_unhash() {
852 let (fs, _t) = test_fs();
853 fs.write("/", "target.md", "x").unwrap();
854 let h = hash_filename("target.md");
855 assert_eq!(fs.unhash("/", &h).unwrap(), "target.md");
856 }
857
858 #[test]
859 fn test_filter_functions() {
860 let f = FileEntry::new(
861 "a.md".into(),
862 "h".into(),
863 "A".into(),
864 0,
865 true,
866 false,
867 "/".into(),
868 );
869 let d = FileEntry::new(
870 "dir".into(),
871 "h".into(),
872 "Dir".into(),
873 0,
874 false,
875 true,
876 "/".into(),
877 );
878 assert_eq!(only_dirs(&[f.clone(), d.clone()]).len(), 1);
879 assert_eq!(only_files(&[f.clone(), d]).len(), 1);
880 }
881
882 #[test]
883 fn test_quota_enforcement() {
884 let dir = TempDir::new().unwrap();
885 let fs = VirtualFs::new(dir.path().to_path_buf())
886 .unwrap()
887 .with_quota(1); assert!(fs.write("/", "big.md", &"x".repeat(2048)).is_err());
889 }
890
891 #[test]
892 fn test_read_write_bytes() {
893 let (fs, _t) = test_fs();
894 let data: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A]; fs.write_bytes("media", "image.png", data).unwrap();
896 let read_back = fs.read_bytes("media", "image.png").unwrap();
897 assert_eq!(read_back, data);
898 }
899
900 #[test]
901 fn test_write_bytes_quota() {
902 let dir = TempDir::new().unwrap();
903 let fs = VirtualFs::new(dir.path().to_path_buf())
904 .unwrap()
905 .with_quota(1); let big = vec![0u8; 2048];
907 assert!(fs.write_bytes("/", "big.bin", &big).is_err());
908 }
909
910 #[test]
911 fn test_path_bytes_roundtrip() {
912 let (fs, _t) = test_fs();
913 let data = b"\x00\x01\x02\xFF binary data";
914 fs.write_path_bytes("sub/file.bin", data).unwrap();
915 let read_back = fs.read_path_bytes("sub/file.bin").unwrap();
916 assert_eq!(read_back, data);
917 }
918}