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> {
237 let vfs = self.build()?;
239
240 let base = expand_tilde(base_path.as_ref())?;
242
243 for path in vfs.all_paths() {
245 let content = vfs.read_file(path)?;
246 write_file_atomic(&base, path.as_str(), content)?;
247 }
248
249 Ok(vfs)
250 }
251
252 pub fn build(self) -> Result<FileSystem> {
281 if let Some(error) = self.errors.into_iter().next() {
282 return Err(error);
283 }
284 Ok(self.vfs)
285 }
286
287 #[must_use]
303 pub fn file_count(&self) -> usize {
304 self.vfs.file_count()
305 }
306}
307
308fn expand_tilde(path: &Path) -> Result<PathBuf> {
314 let path_str = path.to_str().ok_or_else(|| FilesError::InvalidPath {
315 path: path.display().to_string(),
316 })?;
317
318 if path_str.starts_with("~/") || path_str == "~" {
319 let home = dirs::home_dir().ok_or_else(|| FilesError::IoError {
320 path: path_str.to_string(),
321 source: std::io::Error::new(
322 std::io::ErrorKind::NotFound,
323 "Cannot determine home directory",
324 ),
325 })?;
326
327 if path_str == "~" {
328 Ok(home)
329 } else {
330 Ok(home.join(&path_str[2..]))
331 }
332 } else {
333 Ok(path.to_path_buf())
334 }
335}
336
337fn write_file_atomic(base_path: &Path, vfs_path: &str, content: &str) -> Result<()> {
355 let relative_path = vfs_path.strip_prefix('/').unwrap_or(vfs_path);
357
358 if relative_path.contains("..") {
360 return Err(FilesError::InvalidPathComponent {
361 path: vfs_path.to_string(),
362 });
363 }
364
365 let disk_path = base_path.join(relative_path);
367
368 if let Some(parent) = disk_path.parent() {
370 fs::create_dir_all(parent).map_err(|e| FilesError::IoError {
371 path: parent.display().to_string(),
372 source: e,
373 })?;
374 }
375
376 let temp_path = disk_path.with_added_extension("tmp");
378
379 fs::write(&temp_path, content).map_err(|e| FilesError::IoError {
380 path: temp_path.display().to_string(),
381 source: e,
382 })?;
383
384 fs::rename(&temp_path, &disk_path).map_err(|e| FilesError::IoError {
385 path: disk_path.display().to_string(),
386 source: e,
387 })?;
388
389 Ok(())
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use mcp_execution_codegen::GeneratedFile;
396 use std::fs;
397 use tempfile::TempDir;
398
399 #[test]
400 fn test_builder_new() {
401 let builder = FilesBuilder::new();
402 let vfs = builder.build().unwrap();
403 assert_eq!(vfs.file_count(), 0);
404 }
405
406 #[test]
407 fn test_builder_default() {
408 let builder = FilesBuilder::default();
409 let vfs = builder.build().unwrap();
410 assert_eq!(vfs.file_count(), 0);
411 }
412
413 #[test]
414 fn test_add_file() {
415 let vfs = FilesBuilder::new()
416 .add_file("/test.ts", "content")
417 .build()
418 .unwrap();
419
420 assert_eq!(vfs.file_count(), 1);
421 assert_eq!(vfs.read_file("/test.ts").unwrap(), "content");
422 }
423
424 #[test]
425 fn test_add_file_invalid_path() {
426 let result = FilesBuilder::new()
427 .add_file("relative/path", "content")
428 .build();
429
430 assert!(result.is_err());
431 assert!(result.unwrap_err().is_invalid_path());
432 }
433
434 #[test]
435 fn test_add_files() {
436 let files = vec![("/file1.ts", "content1"), ("/file2.ts", "content2")];
437
438 let vfs = FilesBuilder::new().add_files(files).build().unwrap();
439
440 assert_eq!(vfs.file_count(), 2);
441 assert_eq!(vfs.read_file("/file1.ts").unwrap(), "content1");
442 assert_eq!(vfs.read_file("/file2.ts").unwrap(), "content2");
443 }
444
445 #[test]
446 fn test_from_generated_code() {
447 let mut code = GeneratedCode::new();
448 code.add_file(GeneratedFile {
449 path: "manifest.json".to_string(),
450 content: "{}".to_string(),
451 });
452 code.add_file(GeneratedFile {
453 path: "types.ts".to_string(),
454 content: "export {};".to_string(),
455 });
456
457 let vfs = FilesBuilder::from_generated_code(code, "/mcp-tools/servers/test")
458 .build()
459 .unwrap();
460
461 assert_eq!(vfs.file_count(), 2);
462 assert!(vfs.exists("/mcp-tools/servers/test/manifest.json"));
463 assert!(vfs.exists("/mcp-tools/servers/test/types.ts"));
464 }
465
466 #[test]
467 fn test_from_generated_code_nested_paths() {
468 let mut code = GeneratedCode::new();
469 code.add_file(GeneratedFile {
470 path: "tools/sendMessage.ts".to_string(),
471 content: "export function sendMessage() {}".to_string(),
472 });
473
474 let vfs = FilesBuilder::from_generated_code(code, "/mcp-tools/servers/test")
475 .build()
476 .unwrap();
477
478 assert!(vfs.exists("/mcp-tools/servers/test/tools/sendMessage.ts"));
479 }
480
481 #[test]
482 fn test_file_count() {
483 let mut builder = FilesBuilder::new();
484 assert_eq!(builder.file_count(), 0);
485
486 builder = builder.add_file("/test1.ts", "");
487 assert_eq!(builder.file_count(), 1);
488
489 builder = builder.add_file("/test2.ts", "");
490 assert_eq!(builder.file_count(), 2);
491 }
492
493 #[test]
494 fn test_chaining() {
495 let vfs = FilesBuilder::new()
496 .add_file("/file1.ts", "content1")
497 .add_file("/file2.ts", "content2")
498 .add_file("/file3.ts", "content3")
499 .build()
500 .unwrap();
501
502 assert_eq!(vfs.file_count(), 3);
503 }
504
505 #[test]
506 fn test_error_collection() {
507 let result = FilesBuilder::new()
508 .add_file("/valid.ts", "content")
509 .add_file("invalid", "content") .add_file("/another-valid.ts", "content")
511 .build();
512
513 assert!(result.is_err());
515 }
516
517 #[test]
518 fn test_from_generated_code_with_additional_files() {
519 let mut code = GeneratedCode::new();
520 code.add_file(GeneratedFile {
521 path: "generated.ts".to_string(),
522 content: "// generated".to_string(),
523 });
524
525 let vfs = FilesBuilder::from_generated_code(code, "/mcp-tools/servers/test")
526 .add_file("/mcp-tools/servers/test/manual.ts", "// manual")
527 .build()
528 .unwrap();
529
530 assert_eq!(vfs.file_count(), 2);
531 assert!(vfs.exists("/mcp-tools/servers/test/generated.ts"));
532 assert!(vfs.exists("/mcp-tools/servers/test/manual.ts"));
533 }
534
535 #[test]
538 fn test_build_and_export_creates_files() {
539 let temp_dir = TempDir::new().unwrap();
540
541 let vfs = FilesBuilder::new()
542 .add_file("/test.ts", "export const VERSION = '1.0';")
543 .build_and_export(temp_dir.path())
544 .unwrap();
545
546 let file_path = temp_dir.path().join("test.ts");
548 assert!(file_path.exists(), "File should exist on disk");
549
550 let content = fs::read_to_string(&file_path).unwrap();
552 assert_eq!(content, "export const VERSION = '1.0';");
553
554 assert_eq!(vfs.file_count(), 1);
556 assert_eq!(
557 vfs.read_file("/test.ts").unwrap(),
558 "export const VERSION = '1.0';"
559 );
560 }
561
562 #[test]
563 fn test_build_and_export_preserves_structure() {
564 let temp_dir = TempDir::new().unwrap();
565
566 let vfs = FilesBuilder::new()
567 .add_file("/index.ts", "export {};")
568 .add_file("/tools/create.ts", "export function create() {}")
569 .add_file("/tools/update.ts", "export function update() {}")
570 .add_file("/types/models.ts", "export type Model = {};")
571 .build_and_export(temp_dir.path())
572 .unwrap();
573
574 assert!(temp_dir.path().join("index.ts").exists());
576 assert!(temp_dir.path().join("tools").is_dir());
577 assert!(temp_dir.path().join("tools/create.ts").exists());
578 assert!(temp_dir.path().join("tools/update.ts").exists());
579 assert!(temp_dir.path().join("types").is_dir());
580 assert!(temp_dir.path().join("types/models.ts").exists());
581
582 assert_eq!(vfs.file_count(), 4);
584 }
585
586 #[test]
587 fn test_build_and_export_creates_parent_dirs() {
588 let temp_dir = TempDir::new().unwrap();
589
590 let vfs = FilesBuilder::new()
591 .add_file("/deeply/nested/path/to/file.ts", "content")
592 .build_and_export(temp_dir.path())
593 .unwrap();
594
595 let file_path = temp_dir.path().join("deeply/nested/path/to/file.ts");
596 assert!(file_path.exists());
597 assert_eq!(fs::read_to_string(file_path).unwrap(), "content");
598 assert_eq!(vfs.file_count(), 1);
599 }
600
601 #[test]
602 fn test_build_and_export_overwrites_existing() {
603 let temp_dir = TempDir::new().unwrap();
604
605 let vfs1 = FilesBuilder::new()
607 .add_file("/test.ts", "original content")
608 .build_and_export(temp_dir.path())
609 .unwrap();
610
611 assert_eq!(vfs1.file_count(), 1);
612 let file_path = temp_dir.path().join("test.ts");
613 assert_eq!(fs::read_to_string(&file_path).unwrap(), "original content");
614
615 let vfs2 = FilesBuilder::new()
617 .add_file("/test.ts", "updated content")
618 .build_and_export(temp_dir.path())
619 .unwrap();
620
621 assert_eq!(vfs2.file_count(), 1);
622 assert_eq!(fs::read_to_string(&file_path).unwrap(), "updated content");
623 }
624
625 #[test]
626 fn test_build_and_export_returns_vfs() {
627 let temp_dir = TempDir::new().unwrap();
628
629 let vfs = FilesBuilder::new()
630 .add_file("/file1.ts", "content1")
631 .add_file("/file2.ts", "content2")
632 .build_and_export(temp_dir.path())
633 .unwrap();
634
635 assert_eq!(vfs.file_count(), 2);
637 assert!(vfs.exists("/file1.ts"));
638 assert!(vfs.exists("/file2.ts"));
639 assert_eq!(vfs.read_file("/file1.ts").unwrap(), "content1");
640 assert_eq!(vfs.read_file("/file2.ts").unwrap(), "content2");
641 }
642
643 #[test]
644 fn test_build_and_export_with_invalid_path_in_vfs() {
645 let temp_dir = TempDir::new().unwrap();
646
647 let result = FilesBuilder::new()
648 .add_file("/valid.ts", "content")
649 .add_file("invalid/relative", "content")
650 .build_and_export(temp_dir.path());
651
652 assert!(result.is_err());
653 let err = result.unwrap_err();
654 assert!(err.is_invalid_path());
655 }
656
657 #[test]
658 fn test_build_and_export_multiple_files() {
659 let temp_dir = TempDir::new().unwrap();
660
661 let files = vec![
662 ("/index.ts", "export {};"),
663 ("/tool1.ts", "export function tool1() {}"),
664 ("/tool2.ts", "export function tool2() {}"),
665 ("/manifest.json", r#"{"version": "1.0.0"}"#),
666 ];
667
668 let vfs = FilesBuilder::new()
669 .add_files(files)
670 .build_and_export(temp_dir.path())
671 .unwrap();
672
673 assert_eq!(vfs.file_count(), 4);
674 assert!(temp_dir.path().join("index.ts").exists());
675 assert!(temp_dir.path().join("tool1.ts").exists());
676 assert!(temp_dir.path().join("tool2.ts").exists());
677 assert!(temp_dir.path().join("manifest.json").exists());
678 }
679
680 #[test]
681 fn test_build_and_export_empty_vfs() {
682 let temp_dir = TempDir::new().unwrap();
683
684 let vfs = FilesBuilder::new()
685 .build_and_export(temp_dir.path())
686 .unwrap();
687
688 assert_eq!(vfs.file_count(), 0);
689 assert!(temp_dir.path().exists());
691 }
692
693 #[test]
694 fn test_expand_tilde_expands_home() {
695 let path = Path::new("~/test/path");
696 let expanded = expand_tilde(path).unwrap();
697
698 assert!(!expanded.to_string_lossy().contains('~'));
700
701 assert!(expanded.is_absolute());
703 }
704
705 #[test]
706 fn test_expand_tilde_preserves_absolute() {
707 let path = Path::new("/absolute/path");
708 let expanded = expand_tilde(path).unwrap();
709
710 assert_eq!(expanded, Path::new("/absolute/path"));
711 }
712
713 #[test]
714 fn test_expand_tilde_just_tilde() {
715 let path = Path::new("~");
716 let expanded = expand_tilde(path).unwrap();
717
718 assert!(expanded.is_absolute());
720 assert!(!expanded.to_string_lossy().contains('~'));
721 }
722
723 #[test]
724 fn test_write_file_atomic_directory_traversal() {
725 let temp_dir = TempDir::new().unwrap();
726
727 let result = write_file_atomic(temp_dir.path(), "/../etc/passwd", "malicious");
728
729 assert!(result.is_err());
730 assert!(result.unwrap_err().is_invalid_path());
731 }
732
733 #[test]
734 fn test_write_file_atomic_creates_parents() {
735 let temp_dir = TempDir::new().unwrap();
736
737 write_file_atomic(
738 temp_dir.path(),
739 "/deep/nested/structure/file.txt",
740 "content",
741 )
742 .unwrap();
743
744 let file_path = temp_dir.path().join("deep/nested/structure/file.txt");
745 assert!(file_path.exists());
746 assert_eq!(fs::read_to_string(file_path).unwrap(), "content");
747 }
748
749 #[test]
750 fn test_build_and_export_from_generated_code() {
751 let temp_dir = TempDir::new().unwrap();
752
753 let mut code = GeneratedCode::new();
754 code.add_file(GeneratedFile {
755 path: "index.ts".to_string(),
756 content: "export {};".to_string(),
757 });
758 code.add_file(GeneratedFile {
759 path: "tools/create.ts".to_string(),
760 content: "export function create() {}".to_string(),
761 });
762
763 let vfs = FilesBuilder::from_generated_code(code, "/github")
764 .build_and_export(temp_dir.path())
765 .unwrap();
766
767 assert_eq!(vfs.file_count(), 2);
768 assert!(temp_dir.path().join("github/index.ts").exists());
769 assert!(temp_dir.path().join("github/tools/create.ts").exists());
770 }
771
772 #[test]
773 fn test_build_and_export_unicode_content() {
774 let temp_dir = TempDir::new().unwrap();
775
776 let vfs = FilesBuilder::new()
777 .add_file("/unicode.ts", "export const emoji = '🚀';")
778 .build_and_export(temp_dir.path())
779 .unwrap();
780
781 let content = fs::read_to_string(temp_dir.path().join("unicode.ts")).unwrap();
782 assert_eq!(content, "export const emoji = '🚀';");
783 assert_eq!(vfs.file_count(), 1);
784 }
785
786 #[test]
787 fn test_build_and_export_large_content() {
788 let temp_dir = TempDir::new().unwrap();
789
790 let large_content = "x".repeat(100_000);
792
793 let vfs = FilesBuilder::new()
794 .add_file("/large.ts", &large_content)
795 .build_and_export(temp_dir.path())
796 .unwrap();
797
798 let content = fs::read_to_string(temp_dir.path().join("large.ts")).unwrap();
799 assert_eq!(content.len(), 100_000);
800 assert_eq!(vfs.file_count(), 1);
801 }
802}