diff --git a/server/internal/storage/files.go b/server/internal/storage/files.go index 87c5632..867e33d 100644 --- a/server/internal/storage/files.go +++ b/server/internal/storage/files.go @@ -14,6 +14,7 @@ type FileManager interface { FindFileByName(userID, workspaceID int, filename string) ([]string, error) GetFileContent(userID, workspaceID int, filePath string) ([]byte, error) SaveFile(userID, workspaceID int, filePath string, content []byte) error + MoveFile(userID, workspaceID int, srcPath string, dstPath string) error DeleteFile(userID, workspaceID int, filePath string) error GetFileStats(userID, workspaceID int) (*FileCountStats, error) GetTotalFileStats() (*FileCountStats, error) @@ -174,6 +175,34 @@ func (s *Service) SaveFile(userID, workspaceID int, filePath string, content []b return nil } +// MoveFile moves a file from srcPath to dstPath within the workspace directory. +// Both paths must be relative to the workspace directory given by userID and workspaceID. +// If the destination file already exists, it will be overwritten. +func (s *Service) MoveFile(userID, workspaceID int, srcPath string, dstPath string) error { + log := getLogger() + + srcFullPath, err := s.ValidatePath(userID, workspaceID, srcPath) + if err != nil { + return err + } + + dstFullPath, err := s.ValidatePath(userID, workspaceID, dstPath) + if err != nil { + return err + } + + if err := s.fs.MoveFile(srcFullPath, dstFullPath); err != nil { + return fmt.Errorf("failed to move file: %w", err) + } + + log.Debug("file moved", + "userID", userID, + "workspaceID", workspaceID, + "src", srcPath, + "dst", dstPath) + return nil +} + // DeleteFile deletes the file at the given filePath. // Path must be a relative path within the workspace directory given by userID and workspaceID. func (s *Service) DeleteFile(userID, workspaceID int, filePath string) error { diff --git a/server/internal/storage/files_test.go b/server/internal/storage/files_test.go index 2a23cff..d2123f4 100644 --- a/server/internal/storage/files_test.go +++ b/server/internal/storage/files_test.go @@ -407,3 +407,105 @@ func TestDeleteFile(t *testing.T) { }) } } + +func TestMoveFile(t *testing.T) { + mockFS := NewMockFS() + s := storage.NewServiceWithOptions("test-root", storage.Options{ + Fs: mockFS, + NewGitClient: nil, + }) + + testCases := []struct { + name string + userID int + workspaceID int + srcPath string + dstPath string + mockErr error + wantErr bool + }{ + { + name: "successful move", + userID: 1, + workspaceID: 1, + srcPath: "test.md", + dstPath: "moved.md", + mockErr: nil, + wantErr: false, + }, + { + name: "move to subdirectory", + userID: 1, + workspaceID: 1, + srcPath: "test.md", + dstPath: "subdir/test.md", + mockErr: nil, + wantErr: false, + }, + { + name: "invalid source path", + userID: 1, + workspaceID: 1, + srcPath: "../../../etc/passwd", + dstPath: "test.md", + mockErr: nil, + wantErr: true, + }, + { + name: "invalid destination path", + userID: 1, + workspaceID: 1, + srcPath: "test.md", + dstPath: "../../../etc/passwd", + mockErr: nil, + wantErr: true, + }, + { + name: "filesystem move error", + userID: 1, + workspaceID: 1, + srcPath: "test.md", + dstPath: "moved.md", + mockErr: fs.ErrPermission, + wantErr: true, + }, + { + name: "same source and destination", + userID: 1, + workspaceID: 1, + srcPath: "test.md", + dstPath: "test.md", + mockErr: nil, + wantErr: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockFS.MoveFileError = tc.mockErr + err := s.MoveFile(tc.userID, tc.workspaceID, tc.srcPath, tc.dstPath) + + if tc.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedSrcPath := filepath.Join("test-root", "1", "1", tc.srcPath) + expectedDstPath := filepath.Join("test-root", "1", "1", tc.dstPath) + + if dstPath, ok := mockFS.MoveCalls[expectedSrcPath]; ok { + if dstPath != expectedDstPath { + t.Errorf("move destination = %q, want %q", dstPath, expectedDstPath) + } + } else { + t.Error("expected move call not made") + } + }) + } +} diff --git a/server/internal/storage/filesystem.go b/server/internal/storage/filesystem.go index 0fc3473..5350859 100644 --- a/server/internal/storage/filesystem.go +++ b/server/internal/storage/filesystem.go @@ -10,6 +10,7 @@ import ( type fileSystem interface { ReadFile(path string) ([]byte, error) WriteFile(path string, data []byte, perm fs.FileMode) error + MoveFile(src, dst string) error Remove(path string) error MkdirAll(path string, perm fs.FileMode) error RemoveAll(path string) error @@ -38,6 +39,21 @@ func (f *osFS) WriteFile(path string, data []byte, perm fs.FileMode) error { return os.WriteFile(path, data, perm) } +// MoveFile moves the file from src to dst, overwriting if necessary. +func (f *osFS) MoveFile(src, dst string) error { + if err := os.Rename(src, dst); err != nil { + if os.IsExist(err) { + // If the destination exists, remove it and try again + if err := os.Remove(dst); err != nil && !os.IsNotExist(err) { + return err + } + return os.Rename(src, dst) + } + return err + } + return nil +} + // Remove deletes the file at the given path. func (f *osFS) Remove(path string) error { return os.Remove(path) } diff --git a/server/internal/storage/filesystem_test.go b/server/internal/storage/filesystem_test.go index e717a16..4749e18 100644 --- a/server/internal/storage/filesystem_test.go +++ b/server/internal/storage/filesystem_test.go @@ -43,6 +43,7 @@ type mockFS struct { // Record operations for verification ReadCalls map[string]int WriteCalls map[string][]byte + MoveCalls map[string]string RemoveCalls []string MkdirCalls []string @@ -56,6 +57,7 @@ type mockFS struct { err error } WriteFileError error + MoveFileError error RemoveError error MkdirError error StatError error @@ -66,6 +68,7 @@ func NewMockFS() *mockFS { return &mockFS{ ReadCalls: make(map[string]int), WriteCalls: make(map[string][]byte), + MoveCalls: make(map[string]string), RemoveCalls: make([]string, 0), MkdirCalls: make([]string, 0), ReadFileReturns: make(map[string]struct { @@ -88,6 +91,14 @@ func (m *mockFS) WriteFile(path string, data []byte, _ fs.FileMode) error { return m.WriteFileError } +func (m *mockFS) MoveFile(src, dst string) error { + m.MoveCalls[src] = dst + if src == dst { + return nil // No-op if source and destination are the same + } + return m.MoveFileError +} + func (m *mockFS) Remove(path string) error { m.RemoveCalls = append(m.RemoveCalls, path) return m.RemoveError