1use crate::filesystem::FileSystem;
21use crate::types::{FilesError, Result};
22use mcp_execution_codegen::GeneratedCode;
23use std::fs;
24use std::path::{Path, PathBuf};
25
26#[derive(Debug, Default)]
68pub struct FilesBuilder {
69 vfs: FileSystem,
70 errors: Vec<FilesError>,
71}
72
73impl FilesBuilder {
74 #[must_use]
86 pub fn new() -> Self {
87 Self {
88 vfs: FileSystem::new(),
89 errors: Vec::new(),
90 }
91 }
92
93 #[must_use]
119 pub fn from_generated_code(code: GeneratedCode, base_path: impl AsRef<Path>) -> Self {
120 let mut builder = Self::new();
121 let base = base_path.as_ref().to_string_lossy();
122
123 let base_normalized = if base.ends_with('/') {
125 base.into_owned()
126 } else {
127 format!("{base}/")
128 };
129
130 for file in code.files {
131 let full_path = format!("{}{}", base_normalized, file.path);
134 builder = builder.add_file(full_path.as_str(), file.content);
135 }
136
137 builder
138 }
139
140 #[must_use]
159 pub fn add_file(mut self, path: impl AsRef<Path>, content: impl Into<String>) -> Self {
160 if let Err(e) = self.vfs.add_file(path, content) {
161 self.errors.push(e);
162 }
163 self
164 }
165
166 #[must_use]
189 pub fn add_files<P, C>(mut self, files: impl IntoIterator<Item = (P, C)>) -> Self
190 where
191 P: AsRef<Path>,
192 C: Into<String>,
193 {
194 for (path, content) in files {
195 if let Err(e) = self.vfs.add_file(path, content) {
196 self.errors.push(e);
197 }
198 }
199 self
200 }
201
202 pub fn build_and_export(self, base_path: impl AsRef<Path>) -> Result<FileSystem> {
233 let vfs = self.build()?;
235
236 let base = expand_tilde(base_path.as_ref())?;
238
239 for path in vfs.all_paths() {
241 let content = vfs.read_file(path)?;
242 write_file_atomic(&base, path.as_str(), content)?;
243 }
244
245 Ok(vfs)
246 }
247
248 pub fn build(self) -> Result<FileSystem> {
277 if let Some(error) = self.errors.into_iter().next() {
278 return Err(error);
279 }
280 Ok(self.vfs)
281 }
282
283 #[must_use]
299 pub fn file_count(&self) -> usize {
300 self.vfs.file_count()
301 }
302}
303
304fn expand_tilde(path: &Path) -> Result<PathBuf> {
310 let path_str = path.to_str().ok_or_else(|| FilesError::InvalidPath {
311 path: path.display().to_string(),
312 })?;
313
314 if path_str.starts_with("~/") || path_str == "~" {
315 let home = dirs::home_dir().ok_or_else(|| FilesError::IoError {
316 path: path_str.to_string(),
317 source: std::io::Error::new(
318 std::io::ErrorKind::NotFound,
319 "Cannot determine home directory",
320 ),
321 })?;
322
323 if path_str == "~" {
324 Ok(home)
325 } else {
326 Ok(home.join(&path_str[2..]))
327 }
328 } else {
329 Ok(path.to_path_buf())
330 }
331}
332
333fn write_file_atomic(base_path: &Path, vfs_path: &str, content: &str) -> Result<()> {
351 let relative_path = vfs_path.strip_prefix('/').unwrap_or(vfs_path);
353
354 if relative_path.contains("..") {
356 return Err(FilesError::InvalidPathComponent {
357 path: vfs_path.to_string(),
358 });
359 }
360
361 let disk_path = base_path.join(relative_path);
363
364 if let Some(parent) = disk_path.parent() {
366 fs::create_dir_all(parent).map_err(|e| FilesError::IoError {
367 path: parent.display().to_string(),
368 source: e,
369 })?;
370 }
371
372 let temp_path = disk_path.with_extension("tmp");
374
375 fs::write(&temp_path, content).map_err(|e| FilesError::IoError {
376 path: temp_path.display().to_string(),
377 source: e,
378 })?;
379
380 fs::rename(&temp_path, &disk_path).map_err(|e| FilesError::IoError {
381 path: disk_path.display().to_string(),
382 source: e,
383 })?;
384
385 Ok(())
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use mcp_execution_codegen::GeneratedFile;
392 use std::fs;
393 use tempfile::TempDir;
394
395 #[test]
396 fn test_builder_new() {
397 let builder = FilesBuilder::new();
398 let vfs = builder.build().unwrap();
399 assert_eq!(vfs.file_count(), 0);
400 }
401
402 #[test]
403 fn test_builder_default() {
404 let builder = FilesBuilder::default();
405 let vfs = builder.build().unwrap();
406 assert_eq!(vfs.file_count(), 0);
407 }
408
409 #[test]
410 fn test_add_file() {
411 let vfs = FilesBuilder::new()
412 .add_file("/test.ts", "content")
413 .build()
414 .unwrap();
415
416 assert_eq!(vfs.file_count(), 1);
417 assert_eq!(vfs.read_file("/test.ts").unwrap(), "content");
418 }
419
420 #[test]
421 fn test_add_file_invalid_path() {
422 let result = FilesBuilder::new()
423 .add_file("relative/path", "content")
424 .build();
425
426 assert!(result.is_err());
427 assert!(result.unwrap_err().is_invalid_path());
428 }
429
430 #[test]
431 fn test_add_files() {
432 let files = vec![("/file1.ts", "content1"), ("/file2.ts", "content2")];
433
434 let vfs = FilesBuilder::new().add_files(files).build().unwrap();
435
436 assert_eq!(vfs.file_count(), 2);
437 assert_eq!(vfs.read_file("/file1.ts").unwrap(), "content1");
438 assert_eq!(vfs.read_file("/file2.ts").unwrap(), "content2");
439 }
440
441 #[test]
442 fn test_from_generated_code() {
443 let mut code = GeneratedCode::new();
444 code.add_file(GeneratedFile {
445 path: "manifest.json".to_string(),
446 content: "{}".to_string(),
447 });
448 code.add_file(GeneratedFile {
449 path: "types.ts".to_string(),
450 content: "export {};".to_string(),
451 });
452
453 let vfs = FilesBuilder::from_generated_code(code, "/mcp-tools/servers/test")
454 .build()
455 .unwrap();
456
457 assert_eq!(vfs.file_count(), 2);
458 assert!(vfs.exists("/mcp-tools/servers/test/manifest.json"));
459 assert!(vfs.exists("/mcp-tools/servers/test/types.ts"));
460 }
461
462 #[test]
463 fn test_from_generated_code_nested_paths() {
464 let mut code = GeneratedCode::new();
465 code.add_file(GeneratedFile {
466 path: "tools/sendMessage.ts".to_string(),
467 content: "export function sendMessage() {}".to_string(),
468 });
469
470 let vfs = FilesBuilder::from_generated_code(code, "/mcp-tools/servers/test")
471 .build()
472 .unwrap();
473
474 assert!(vfs.exists("/mcp-tools/servers/test/tools/sendMessage.ts"));
475 }
476
477 #[test]
478 fn test_file_count() {
479 let mut builder = FilesBuilder::new();
480 assert_eq!(builder.file_count(), 0);
481
482 builder = builder.add_file("/test1.ts", "");
483 assert_eq!(builder.file_count(), 1);
484
485 builder = builder.add_file("/test2.ts", "");
486 assert_eq!(builder.file_count(), 2);
487 }
488
489 #[test]
490 fn test_chaining() {
491 let vfs = FilesBuilder::new()
492 .add_file("/file1.ts", "content1")
493 .add_file("/file2.ts", "content2")
494 .add_file("/file3.ts", "content3")
495 .build()
496 .unwrap();
497
498 assert_eq!(vfs.file_count(), 3);
499 }
500
501 #[test]
502 fn test_error_collection() {
503 let result = FilesBuilder::new()
504 .add_file("/valid.ts", "content")
505 .add_file("invalid", "content") .add_file("/another-valid.ts", "content")
507 .build();
508
509 assert!(result.is_err());
511 }
512
513 #[test]
514 fn test_from_generated_code_with_additional_files() {
515 let mut code = GeneratedCode::new();
516 code.add_file(GeneratedFile {
517 path: "generated.ts".to_string(),
518 content: "// generated".to_string(),
519 });
520
521 let vfs = FilesBuilder::from_generated_code(code, "/mcp-tools/servers/test")
522 .add_file("/mcp-tools/servers/test/manual.ts", "// manual")
523 .build()
524 .unwrap();
525
526 assert_eq!(vfs.file_count(), 2);
527 assert!(vfs.exists("/mcp-tools/servers/test/generated.ts"));
528 assert!(vfs.exists("/mcp-tools/servers/test/manual.ts"));
529 }
530
531 #[test]
534 fn test_build_and_export_creates_files() {
535 let temp_dir = TempDir::new().unwrap();
536
537 let vfs = FilesBuilder::new()
538 .add_file("/test.ts", "export const VERSION = '1.0';")
539 .build_and_export(temp_dir.path())
540 .unwrap();
541
542 let file_path = temp_dir.path().join("test.ts");
544 assert!(file_path.exists(), "File should exist on disk");
545
546 let content = fs::read_to_string(&file_path).unwrap();
548 assert_eq!(content, "export const VERSION = '1.0';");
549
550 assert_eq!(vfs.file_count(), 1);
552 assert_eq!(
553 vfs.read_file("/test.ts").unwrap(),
554 "export const VERSION = '1.0';"
555 );
556 }
557
558 #[test]
559 fn test_build_and_export_preserves_structure() {
560 let temp_dir = TempDir::new().unwrap();
561
562 let vfs = FilesBuilder::new()
563 .add_file("/index.ts", "export {};")
564 .add_file("/tools/create.ts", "export function create() {}")
565 .add_file("/tools/update.ts", "export function update() {}")
566 .add_file("/types/models.ts", "export type Model = {};")
567 .build_and_export(temp_dir.path())
568 .unwrap();
569
570 assert!(temp_dir.path().join("index.ts").exists());
572 assert!(temp_dir.path().join("tools").is_dir());
573 assert!(temp_dir.path().join("tools/create.ts").exists());
574 assert!(temp_dir.path().join("tools/update.ts").exists());
575 assert!(temp_dir.path().join("types").is_dir());
576 assert!(temp_dir.path().join("types/models.ts").exists());
577
578 assert_eq!(vfs.file_count(), 4);
580 }
581
582 #[test]
583 fn test_build_and_export_creates_parent_dirs() {
584 let temp_dir = TempDir::new().unwrap();
585
586 let vfs = FilesBuilder::new()
587 .add_file("/deeply/nested/path/to/file.ts", "content")
588 .build_and_export(temp_dir.path())
589 .unwrap();
590
591 let file_path = temp_dir.path().join("deeply/nested/path/to/file.ts");
592 assert!(file_path.exists());
593 assert_eq!(fs::read_to_string(file_path).unwrap(), "content");
594 assert_eq!(vfs.file_count(), 1);
595 }
596
597 #[test]
598 fn test_build_and_export_overwrites_existing() {
599 let temp_dir = TempDir::new().unwrap();
600
601 let vfs1 = FilesBuilder::new()
603 .add_file("/test.ts", "original content")
604 .build_and_export(temp_dir.path())
605 .unwrap();
606
607 assert_eq!(vfs1.file_count(), 1);
608 let file_path = temp_dir.path().join("test.ts");
609 assert_eq!(fs::read_to_string(&file_path).unwrap(), "original content");
610
611 let vfs2 = FilesBuilder::new()
613 .add_file("/test.ts", "updated content")
614 .build_and_export(temp_dir.path())
615 .unwrap();
616
617 assert_eq!(vfs2.file_count(), 1);
618 assert_eq!(fs::read_to_string(&file_path).unwrap(), "updated content");
619 }
620
621 #[test]
622 fn test_build_and_export_returns_vfs() {
623 let temp_dir = TempDir::new().unwrap();
624
625 let vfs = FilesBuilder::new()
626 .add_file("/file1.ts", "content1")
627 .add_file("/file2.ts", "content2")
628 .build_and_export(temp_dir.path())
629 .unwrap();
630
631 assert_eq!(vfs.file_count(), 2);
633 assert!(vfs.exists("/file1.ts"));
634 assert!(vfs.exists("/file2.ts"));
635 assert_eq!(vfs.read_file("/file1.ts").unwrap(), "content1");
636 assert_eq!(vfs.read_file("/file2.ts").unwrap(), "content2");
637 }
638
639 #[test]
640 fn test_build_and_export_with_invalid_path_in_vfs() {
641 let temp_dir = TempDir::new().unwrap();
642
643 let result = FilesBuilder::new()
644 .add_file("/valid.ts", "content")
645 .add_file("invalid/relative", "content")
646 .build_and_export(temp_dir.path());
647
648 assert!(result.is_err());
649 let err = result.unwrap_err();
650 assert!(err.is_invalid_path());
651 }
652
653 #[test]
654 fn test_build_and_export_multiple_files() {
655 let temp_dir = TempDir::new().unwrap();
656
657 let files = vec![
658 ("/index.ts", "export {};"),
659 ("/tool1.ts", "export function tool1() {}"),
660 ("/tool2.ts", "export function tool2() {}"),
661 ("/manifest.json", r#"{"version": "1.0.0"}"#),
662 ];
663
664 let vfs = FilesBuilder::new()
665 .add_files(files)
666 .build_and_export(temp_dir.path())
667 .unwrap();
668
669 assert_eq!(vfs.file_count(), 4);
670 assert!(temp_dir.path().join("index.ts").exists());
671 assert!(temp_dir.path().join("tool1.ts").exists());
672 assert!(temp_dir.path().join("tool2.ts").exists());
673 assert!(temp_dir.path().join("manifest.json").exists());
674 }
675
676 #[test]
677 fn test_build_and_export_empty_vfs() {
678 let temp_dir = TempDir::new().unwrap();
679
680 let vfs = FilesBuilder::new()
681 .build_and_export(temp_dir.path())
682 .unwrap();
683
684 assert_eq!(vfs.file_count(), 0);
685 assert!(temp_dir.path().exists());
687 }
688
689 #[test]
690 fn test_expand_tilde_expands_home() {
691 let path = Path::new("~/test/path");
692 let expanded = expand_tilde(path).unwrap();
693
694 assert!(!expanded.to_string_lossy().contains('~'));
696
697 assert!(expanded.is_absolute());
699 }
700
701 #[test]
702 fn test_expand_tilde_preserves_absolute() {
703 let path = Path::new("/absolute/path");
704 let expanded = expand_tilde(path).unwrap();
705
706 assert_eq!(expanded, Path::new("/absolute/path"));
707 }
708
709 #[test]
710 fn test_expand_tilde_just_tilde() {
711 let path = Path::new("~");
712 let expanded = expand_tilde(path).unwrap();
713
714 assert!(expanded.is_absolute());
716 assert!(!expanded.to_string_lossy().contains('~'));
717 }
718
719 #[test]
720 fn test_write_file_atomic_directory_traversal() {
721 let temp_dir = TempDir::new().unwrap();
722
723 let result = write_file_atomic(temp_dir.path(), "/../etc/passwd", "malicious");
724
725 assert!(result.is_err());
726 assert!(result.unwrap_err().is_invalid_path());
727 }
728
729 #[test]
730 fn test_write_file_atomic_creates_parents() {
731 let temp_dir = TempDir::new().unwrap();
732
733 write_file_atomic(
734 temp_dir.path(),
735 "/deep/nested/structure/file.txt",
736 "content",
737 )
738 .unwrap();
739
740 let file_path = temp_dir.path().join("deep/nested/structure/file.txt");
741 assert!(file_path.exists());
742 assert_eq!(fs::read_to_string(file_path).unwrap(), "content");
743 }
744
745 #[test]
746 fn test_build_and_export_from_generated_code() {
747 let temp_dir = TempDir::new().unwrap();
748
749 let mut code = GeneratedCode::new();
750 code.add_file(GeneratedFile {
751 path: "index.ts".to_string(),
752 content: "export {};".to_string(),
753 });
754 code.add_file(GeneratedFile {
755 path: "tools/create.ts".to_string(),
756 content: "export function create() {}".to_string(),
757 });
758
759 let vfs = FilesBuilder::from_generated_code(code, "/github")
760 .build_and_export(temp_dir.path())
761 .unwrap();
762
763 assert_eq!(vfs.file_count(), 2);
764 assert!(temp_dir.path().join("github/index.ts").exists());
765 assert!(temp_dir.path().join("github/tools/create.ts").exists());
766 }
767
768 #[test]
769 fn test_build_and_export_unicode_content() {
770 let temp_dir = TempDir::new().unwrap();
771
772 let vfs = FilesBuilder::new()
773 .add_file("/unicode.ts", "export const emoji = '🚀';")
774 .build_and_export(temp_dir.path())
775 .unwrap();
776
777 let content = fs::read_to_string(temp_dir.path().join("unicode.ts")).unwrap();
778 assert_eq!(content, "export const emoji = '🚀';");
779 assert_eq!(vfs.file_count(), 1);
780 }
781
782 #[test]
783 fn test_build_and_export_large_content() {
784 let temp_dir = TempDir::new().unwrap();
785
786 let large_content = "x".repeat(100_000);
788
789 let vfs = FilesBuilder::new()
790 .add_file("/large.ts", &large_content)
791 .build_and_export(temp_dir.path())
792 .unwrap();
793
794 let content = fs::read_to_string(temp_dir.path().join("large.ts")).unwrap();
795 assert_eq!(content.len(), 100_000);
796 assert_eq!(vfs.file_count(), 1);
797 }
798}