//! Integration tests for file operations (read, write, diff, edit). //! //! These tests use real filesystem operations with temporary directories //! to verify end-to-end functionality including sandboxing and error handling. use dirigent_tools::config::{EolPolicy, PermissionConfig, SandboxConfig}; use dirigent_tools::error::ToolError; use dirigent_tools::fs::diff::generate_diff; use dirigent_tools::fs::edit::{edit_file, EditFileRequest, EditOperation}; use dirigent_tools::fs::read::{read_text_file, ReadTextFileRequest}; use dirigent_tools::fs::write::{write_text_file, WriteTextFileRequest}; use dirigent_tools::permission::check::PermissionContext; use dirigent_tools::permission::whitelist::CompiledWhitelist; use std::path::PathBuf; use tempfile::TempDir; /// Create a test configuration with a single allowed root. fn test_config(allowed_root: PathBuf) -> SandboxConfig { let mut config = SandboxConfig::default(); // Ensure the root is canonical let canonical_root = dunce::canonicalize(&allowed_root) .unwrap_or_else(|_| panic!("Failed to canonicalize test root: {:?}", allowed_root)); config.allowed_roots = vec![canonical_root]; config.write_enabled = true; config.read_enabled = true; config } /// Create a test permission config with YOLO mode (no prompts). fn test_permission_config() -> PermissionConfig { PermissionConfig::default() } /// Create a test permission context for a test connector. fn test_permission_context() -> PermissionContext { let whitelist = CompiledWhitelist::compile(&Default::default()).unwrap(); PermissionContext::new( "test-connector".to_string(), Some("test-session".to_string()), whitelist, ) } // ============================================================================= // Read Operation Tests // ============================================================================= #[tokio::test] async fn test_read_entire_file() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("test.txt"); // Create test file std::fs::write(&file_path, "line1\nline2\nline3").unwrap(); let config = test_config(temp_dir.path().to_path_buf()); let request = ReadTextFileRequest { path: file_path.to_string_lossy().to_string(), line: None, limit: None, }; let response = read_text_file(request, &config).await.unwrap(); assert_eq!(response.content, "line1\nline2\nline3"); } #[tokio::test] async fn test_read_with_line_limit() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("test.txt"); // Create test file std::fs::write(&file_path, "line1\nline2\nline3\nline4\nline5").unwrap(); let config = test_config(temp_dir.path().to_path_buf()); // Read from line 2, limit 2 lines let request = ReadTextFileRequest { path: file_path.to_string_lossy().to_string(), line: Some(2), limit: Some(2), }; let response = read_text_file(request, &config).await.unwrap(); assert_eq!(response.content, "line2\nline3"); } #[tokio::test] async fn test_read_only_limit() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("test.txt"); std::fs::write(&file_path, "line1\nline2\nline3\nline4").unwrap(); let config = test_config(temp_dir.path().to_path_buf()); let request = ReadTextFileRequest { path: file_path.to_string_lossy().to_string(), line: None, limit: Some(2), }; let response = read_text_file(request, &config).await.unwrap(); assert_eq!(response.content, "line1\nline2"); } #[tokio::test] async fn test_read_file_not_found() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("nonexistent.txt"); let config = test_config(temp_dir.path().to_path_buf()); let request = ReadTextFileRequest { path: file_path.to_string_lossy().to_string(), line: None, limit: None, }; let result = read_text_file(request, &config).await; assert!(matches!(result, Err(ToolError::NotFound { .. }))); } #[tokio::test] async fn test_read_non_utf8() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("binary.bin"); // Write invalid UTF-8 std::fs::write(&file_path, &[0xFF, 0xFE, 0xFD]).unwrap(); let config = test_config(temp_dir.path().to_path_buf()); let request = ReadTextFileRequest { path: file_path.to_string_lossy().to_string(), line: None, limit: None, }; let result = read_text_file(request, &config).await; assert!(matches!(result, Err(ToolError::EncodingUnsupported { .. }))); } #[tokio::test] async fn test_read_outside_sandbox() { let temp_dir = TempDir::new().unwrap(); let other_temp_dir = TempDir::new().unwrap(); let file_path = other_temp_dir.path().join("test.txt"); std::fs::write(&file_path, "content").unwrap(); // Config only allows temp_dir, not other_temp_dir let config = test_config(temp_dir.path().to_path_buf()); let request = ReadTextFileRequest { path: file_path.to_string_lossy().to_string(), line: None, limit: None, }; let result = read_text_file(request, &config).await; assert!(matches!(result, Err(ToolError::SandboxViolation { .. }))); } #[tokio::test] async fn test_read_disabled() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("test.txt"); std::fs::write(&file_path, "content").unwrap(); let mut config = test_config(temp_dir.path().to_path_buf()); config.read_enabled = false; let request = ReadTextFileRequest { path: file_path.to_string_lossy().to_string(), line: None, limit: None, }; let result = read_text_file(request, &config).await; assert!(matches!(result, Err(ToolError::PermissionDenied { .. }))); } // ============================================================================= // Write Operation Tests // ============================================================================= #[tokio::test] async fn test_write_new_file() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("new.txt"); let config = test_config(temp_dir.path().to_path_buf()); let request = WriteTextFileRequest { path: file_path.to_string_lossy().to_string(), content: "hello world".to_string(), }; write_text_file(request, &config, &test_permission_config(), &test_permission_context()).await.unwrap(); // Verify file was written let content = std::fs::read_to_string(&file_path).unwrap(); assert_eq!(content, "hello world"); } #[tokio::test] async fn test_write_overwrite_existing() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("existing.txt"); std::fs::write(&file_path, "old content").unwrap(); let config = test_config(temp_dir.path().to_path_buf()); let request = WriteTextFileRequest { path: file_path.to_string_lossy().to_string(), content: "new content".to_string(), }; write_text_file(request, &config, &test_permission_config(), &test_permission_context()).await.unwrap(); let content = std::fs::read_to_string(&file_path).unwrap(); assert_eq!(content, "new content"); } #[tokio::test] async fn test_write_create_parent_directories() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("subdir").join("nested").join("file.txt"); let config = test_config(temp_dir.path().to_path_buf()); let request = WriteTextFileRequest { path: file_path.to_string_lossy().to_string(), content: "nested content".to_string(), }; write_text_file(request, &config, &test_permission_config(), &test_permission_context()).await.unwrap(); let content = std::fs::read_to_string(&file_path).unwrap(); assert_eq!(content, "nested content"); } #[tokio::test] async fn test_write_eol_normalization_lf() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("eol_lf.txt"); let mut config = test_config(temp_dir.path().to_path_buf()); config.eol_policy = EolPolicy::Lf; let request = WriteTextFileRequest { path: file_path.to_string_lossy().to_string(), content: "line1\r\nline2\rline3\n".to_string(), }; write_text_file(request, &config, &test_permission_config(), &test_permission_context()).await.unwrap(); let content = std::fs::read_to_string(&file_path).unwrap(); assert_eq!(content, "line1\nline2\nline3\n"); } #[tokio::test] async fn test_write_eol_normalization_crlf() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("eol_crlf.txt"); let mut config = test_config(temp_dir.path().to_path_buf()); config.eol_policy = EolPolicy::Crlf; let request = WriteTextFileRequest { path: file_path.to_string_lossy().to_string(), content: "line1\nline2\rline3\r\n".to_string(), }; write_text_file(request, &config, &test_permission_config(), &test_permission_context()).await.unwrap(); let content = std::fs::read_to_string(&file_path).unwrap(); assert_eq!(content, "line1\r\nline2\r\nline3\r\n"); } #[tokio::test] async fn test_write_size_limit_exceeded() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("large.txt"); let mut config = test_config(temp_dir.path().to_path_buf()); config.max_write_bytes = 10; let request = WriteTextFileRequest { path: file_path.to_string_lossy().to_string(), content: "this is way too much content".to_string(), }; let result = write_text_file(request, &config, &test_permission_config(), &test_permission_context()).await; assert!(matches!(result, Err(ToolError::FileTooLarge { .. }))); } #[tokio::test] async fn test_write_outside_sandbox() { let temp_dir = TempDir::new().unwrap(); let other_temp_dir = TempDir::new().unwrap(); let file_path = other_temp_dir.path().join("test.txt"); let config = test_config(temp_dir.path().to_path_buf()); let request = WriteTextFileRequest { path: file_path.to_string_lossy().to_string(), content: "content".to_string(), }; let result = write_text_file(request, &config, &test_permission_config(), &test_permission_context()).await; assert!(matches!(result, Err(ToolError::SandboxViolation { .. }))); } #[tokio::test] async fn test_write_disabled() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("test.txt"); let mut config = test_config(temp_dir.path().to_path_buf()); config.write_enabled = false; let request = WriteTextFileRequest { path: file_path.to_string_lossy().to_string(), content: "content".to_string(), }; let result = write_text_file(request, &config, &test_permission_config(), &test_permission_context()).await; assert!(matches!(result, Err(ToolError::PermissionDenied { .. }))); } // ============================================================================= // Diff Generation Tests // ============================================================================= #[test] fn test_diff_new_file() { let old = ""; let new = "line1\nline2\nline3"; let path = PathBuf::from("test.txt"); let diff = generate_diff(old, new, &path); assert!(diff.contains("--- /dev/null")); assert!(diff.contains("+++ test.txt")); assert!(diff.contains("+line1")); assert!(diff.contains("+line2")); assert!(diff.contains("+line3")); } #[test] fn test_diff_deleted_file() { let old = "line1\nline2\nline3"; let new = ""; let path = PathBuf::from("test.txt"); let diff = generate_diff(old, new, &path); assert!(diff.contains("--- test.txt")); assert!(diff.contains("+++ /dev/null")); assert!(diff.contains("-line1")); assert!(diff.contains("-line2")); assert!(diff.contains("-line3")); } #[test] fn test_diff_modification() { let old = "line1\nline2\nline3"; let new = "line1\nmodified\nline3"; let path = PathBuf::from("test.txt"); let diff = generate_diff(old, new, &path); assert!(diff.contains("--- test.txt")); assert!(diff.contains("+++ test.txt")); assert!(diff.contains("-line2")); assert!(diff.contains("+modified")); } // ============================================================================= // Edit Operation Tests // ============================================================================= #[tokio::test] async fn test_edit_replace_once() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("edit.txt"); std::fs::write(&file_path, "foo bar foo baz").unwrap(); let config = test_config(temp_dir.path().to_path_buf()); let request = EditFileRequest { path: file_path.to_string_lossy().to_string(), edits: vec![EditOperation::Replace { old_text: "foo".to_string(), new_text: "qux".to_string(), replace_all: false, }], }; let response = edit_file(request, &config, &test_permission_config(), &test_permission_context()).await.unwrap(); // Verify file was edited let content = std::fs::read_to_string(&file_path).unwrap(); assert_eq!(content, "qux bar foo baz"); // Verify diff was generated assert!(response.diff.contains("-foo")); assert!(response.diff.contains("+qux")); } #[tokio::test] async fn test_edit_replace_all() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("edit.txt"); std::fs::write(&file_path, "foo bar foo baz foo").unwrap(); let config = test_config(temp_dir.path().to_path_buf()); let request = EditFileRequest { path: file_path.to_string_lossy().to_string(), edits: vec![EditOperation::Replace { old_text: "foo".to_string(), new_text: "qux".to_string(), replace_all: true, }], }; edit_file(request, &config, &test_permission_config(), &test_permission_context()).await.unwrap(); let content = std::fs::read_to_string(&file_path).unwrap(); assert_eq!(content, "qux bar qux baz qux"); } #[tokio::test] async fn test_edit_multiple_operations() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("edit.txt"); std::fs::write(&file_path, "hello world\nhello rust").unwrap(); let config = test_config(temp_dir.path().to_path_buf()); let request = EditFileRequest { path: file_path.to_string_lossy().to_string(), edits: vec![ EditOperation::Replace { old_text: "hello".to_string(), new_text: "goodbye".to_string(), replace_all: true, }, EditOperation::Replace { old_text: "world".to_string(), new_text: "universe".to_string(), replace_all: false, }, ], }; edit_file(request, &config, &test_permission_config(), &test_permission_context()).await.unwrap(); let content = std::fs::read_to_string(&file_path).unwrap(); assert_eq!(content, "goodbye universe\ngoodbye rust"); } #[tokio::test] async fn test_edit_nonexistent_file() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("nonexistent.txt"); let config = test_config(temp_dir.path().to_path_buf()); let request = EditFileRequest { path: file_path.to_string_lossy().to_string(), edits: vec![EditOperation::Replace { old_text: "foo".to_string(), new_text: "bar".to_string(), replace_all: false, }], }; let result = edit_file(request, &config, &test_permission_config(), &test_permission_context()).await; assert!(matches!(result, Err(ToolError::NotFound { .. }))); } #[tokio::test] async fn test_edit_outside_sandbox() { let temp_dir = TempDir::new().unwrap(); let other_temp_dir = TempDir::new().unwrap(); let file_path = other_temp_dir.path().join("test.txt"); std::fs::write(&file_path, "content").unwrap(); let config = test_config(temp_dir.path().to_path_buf()); let request = EditFileRequest { path: file_path.to_string_lossy().to_string(), edits: vec![EditOperation::Replace { old_text: "content".to_string(), new_text: "modified".to_string(), replace_all: false, }], }; let result = edit_file(request, &config, &test_permission_config(), &test_permission_context()).await; assert!(matches!(result, Err(ToolError::SandboxViolation { .. }))); } // ============================================================================= // Windows-Specific Tests // ============================================================================= #[cfg(windows)] #[tokio::test] async fn test_read_write_windows_line_endings() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("windows.txt"); // Write file with CRLF std::fs::write(&file_path, "line1\r\nline2\r\nline3").unwrap(); let config = test_config(temp_dir.path().to_path_buf()); // Read file let read_request = ReadTextFileRequest { path: file_path.to_string_lossy().to_string(), line: None, limit: None, }; let response = read_text_file(read_request, &config).await.unwrap(); // Content should preserve CRLF when read assert!(response.content.contains("\r\n")); // Write with LF normalization let mut write_config = config.clone(); write_config.eol_policy = EolPolicy::Lf; let write_request = WriteTextFileRequest { path: file_path.to_string_lossy().to_string(), content: response.content, }; write_text_file(write_request, &write_config, &test_permission_config(), &test_permission_context()).await.unwrap(); // Verify normalization happened let final_content = std::fs::read_to_string(&file_path).unwrap(); assert!(!final_content.contains("\r\n")); assert!(final_content.contains("\n")); }