diff --git a/pkg/workspace/rename.go b/pkg/workspace/rename.go index 9dd9b6749..ac42b5b64 100644 --- a/pkg/workspace/rename.go +++ b/pkg/workspace/rename.go @@ -5,9 +5,12 @@ import ( "errors" "fmt" "os" + "path/filepath" + "strings" client2 "github.com/devsy-org/devsy/pkg/client" "github.com/devsy-org/devsy/pkg/config" + devcontainerconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/platform" "github.com/devsy-org/devsy/pkg/provider" @@ -77,6 +80,96 @@ func stopWorkspaceIfRunning( return nil } +type pathReplacer struct { + pairs [][2]string + changed bool +} + +func newPathReplacer( + containerWorkspaceFolder, localWorkspaceFolder, oldName, newName string, +) *pathReplacer { + r := &pathReplacer{} + + if containerWorkspaceFolder != "" { + containerParent := strings.TrimSuffix( + containerWorkspaceFolder, filepath.Base(containerWorkspaceFolder), + ) + r.pairs = append(r.pairs, [2]string{ + containerParent + oldName, + containerParent + newName, + }) + } + + if localWorkspaceFolder != "" { + localParent := strings.TrimSuffix( + localWorkspaceFolder, filepath.Base(localWorkspaceFolder), + ) + r.pairs = append(r.pairs, [2]string{ + localParent + oldName, + localParent + newName, + }) + } + + return r +} + +func (r *pathReplacer) replace(s string) string { + for _, pair := range r.pairs { + if strings.Contains(s, pair[0]) { + s = strings.ReplaceAll(s, pair[0], pair[1]) + r.changed = true + } + } + return s +} + +func (r *pathReplacer) applyToMergedConfig(mc *devcontainerconfig.MergedDevContainerConfig) { + if mc == nil { + return + } + mc.WorkspaceFolder = r.replace(mc.WorkspaceFolder) + if mc.WorkspaceMount != nil { + updated := r.replace(*mc.WorkspaceMount) + mc.WorkspaceMount = &updated + } +} + +// updateWorkspaceResult rewrites workspace_result.json to replace references +// to the old workspace name with the new one. This ensures that cached paths +// like ContainerWorkspaceFolder, LocalWorkspaceFolder, and WorkspaceMount +// stay valid after rename. +func updateWorkspaceResult(devsyConfig *config.Config, oldName, newName string) { + context := devsyConfig.DefaultContext + result, err := provider.LoadWorkspaceResult(context, newName) + if err != nil || result == nil { + return + } + + var containerWSFolder, localWSFolder string + if sc := result.SubstitutionContext; sc != nil { + containerWSFolder = sc.ContainerWorkspaceFolder + localWSFolder = sc.LocalWorkspaceFolder + } + + r := newPathReplacer(containerWSFolder, localWSFolder, oldName, newName) + + if sc := result.SubstitutionContext; sc != nil { + sc.ContainerWorkspaceFolder = r.replace(sc.ContainerWorkspaceFolder) + sc.LocalWorkspaceFolder = r.replace(sc.LocalWorkspaceFolder) + sc.WorkspaceMount = r.replace(sc.WorkspaceMount) + } + r.applyToMergedConfig(result.MergedConfig) + + if !r.changed { + return + } + + ws := &provider.Workspace{ID: newName, Context: context} + if err := provider.SaveWorkspaceResult(ws, result); err != nil { + log.Warnf("failed to update workspace result after rename: %v", err) + } +} + // Rename performs the workspace rename: auto-stops if running, moves the // workspace directory, updates the config ID, and removes the old SSH config // entry. If any step after the directory move fails, the entire operation is @@ -109,6 +202,8 @@ func Rename(ctx context.Context, opts RenameOptions) error { return errors.Join(err, rollbackErr) } + updateWorkspaceResult(opts.DevsyConfig, opts.OldName, opts.NewName) + _ = devssh.RemoveFromConfig( opts.OldName, wsConfig.SSHConfigPath, diff --git a/pkg/workspace/rename_integration_test.go b/pkg/workspace/rename_integration_test.go new file mode 100644 index 000000000..5663af618 --- /dev/null +++ b/pkg/workspace/rename_integration_test.go @@ -0,0 +1,393 @@ +package workspace + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/devsy-org/devsy/pkg/config" + devcontainerconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/provider" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testDefaultContext = "default" + testContainerWSMount = "/workspaces/ws-old" +) + +func setupTestPathManager(t *testing.T) { + t.Helper() + + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) + + config.ResetPathManager() + t.Cleanup(config.ResetPathManager) +} + +func writeWorkspaceResult( + t *testing.T, workspaceID string, result *devcontainerconfig.Result, +) { + t.Helper() + + ws := &provider.Workspace{ID: workspaceID, Context: testDefaultContext} + require.NoError(t, provider.SaveWorkspaceResult(ws, result)) +} + +func loadWorkspaceResult( + t *testing.T, workspaceID string, +) *devcontainerconfig.Result { + t.Helper() + + result, err := provider.LoadWorkspaceResult(testDefaultContext, workspaceID) + require.NoError(t, err) + require.NotNil(t, result) + + return result +} + +func ptrStr(s string) *string { return &s } + +func TestUpdateWorkspaceResult_BasicRename(t *testing.T) { + setupTestPathManager(t) + + oldName := "my-project" + newName := "my-project-renamed" + + result := &devcontainerconfig.Result{ + SubstitutionContext: &devcontainerconfig.SubstitutionContext{ + ContainerWorkspaceFolder: "/workspaces/my-project", + LocalWorkspaceFolder: "/home/user/my-project", + WorkspaceMount: "type=bind,source=/home/user/my-project,target=/workspaces/my-project", + }, + MergedConfig: &devcontainerconfig.MergedDevContainerConfig{}, + } + result.MergedConfig.WorkspaceFolder = "/workspaces/my-project" + result.MergedConfig.WorkspaceMount = ptrStr( + "type=bind,source=/home/user/my-project,target=/workspaces/my-project", + ) + + writeWorkspaceResult(t, newName, result) + + devsyConfig := &config.Config{DefaultContext: testDefaultContext} + updateWorkspaceResult(devsyConfig, oldName, newName) + + got := loadWorkspaceResult(t, newName) + + assert.Equal( + t, + "/workspaces/my-project-renamed", + got.SubstitutionContext.ContainerWorkspaceFolder, + ) + assert.Equal(t, "/home/user/my-project-renamed", got.SubstitutionContext.LocalWorkspaceFolder) + assert.Contains(t, got.SubstitutionContext.WorkspaceMount, "/workspaces/my-project-renamed") + assert.Contains(t, got.SubstitutionContext.WorkspaceMount, "/home/user/my-project-renamed") + assert.Equal(t, "/workspaces/my-project-renamed", got.MergedConfig.WorkspaceFolder) + require.NotNil(t, got.MergedConfig.WorkspaceMount) + assert.Contains(t, *got.MergedConfig.WorkspaceMount, "/workspaces/my-project-renamed") + assert.Contains(t, *got.MergedConfig.WorkspaceMount, "/home/user/my-project-renamed") +} + +func TestUpdateWorkspaceResult_MergedConfigUpdated(t *testing.T) { + setupTestPathManager(t) + + oldName := "app" + newName := "app-v2" + + result := &devcontainerconfig.Result{ + SubstitutionContext: &devcontainerconfig.SubstitutionContext{ + ContainerWorkspaceFolder: "/workspaces/app", + LocalWorkspaceFolder: "/home/dev/app", + WorkspaceMount: "type=bind,source=/home/dev/app,target=/workspaces/app", + }, + MergedConfig: &devcontainerconfig.MergedDevContainerConfig{}, + } + result.MergedConfig.WorkspaceFolder = "/workspaces/app" + result.MergedConfig.WorkspaceMount = ptrStr( + "type=bind,source=/home/dev/app,target=/workspaces/app", + ) + + writeWorkspaceResult(t, newName, result) + + devsyConfig := &config.Config{DefaultContext: testDefaultContext} + updateWorkspaceResult(devsyConfig, oldName, newName) + + got := loadWorkspaceResult(t, newName) + + assert.Equal(t, "/workspaces/app-v2", got.MergedConfig.WorkspaceFolder) + require.NotNil(t, got.MergedConfig.WorkspaceMount) + assert.Equal( + t, + "type=bind,source=/home/dev/app-v2,target=/workspaces/app-v2", + *got.MergedConfig.WorkspaceMount, + ) +} + +func TestUpdateWorkspaceResult_NonDefaultWorkspaceDir(t *testing.T) { + setupTestPathManager(t) + + oldName := "project" + newName := "project-new" + + result := &devcontainerconfig.Result{ + SubstitutionContext: &devcontainerconfig.SubstitutionContext{ + ContainerWorkspaceFolder: "/home/coder/project", + LocalWorkspaceFolder: "/mnt/data/project", + WorkspaceMount: "type=bind,source=/mnt/data/project,target=/home/coder/project", + }, + MergedConfig: &devcontainerconfig.MergedDevContainerConfig{}, + } + result.MergedConfig.WorkspaceFolder = "/home/coder/project" + result.MergedConfig.WorkspaceMount = ptrStr( + "type=bind,source=/mnt/data/project,target=/home/coder/project", + ) + + writeWorkspaceResult(t, newName, result) + + devsyConfig := &config.Config{DefaultContext: testDefaultContext} + updateWorkspaceResult(devsyConfig, oldName, newName) + + got := loadWorkspaceResult(t, newName) + + assert.Equal(t, "/home/coder/project-new", got.SubstitutionContext.ContainerWorkspaceFolder) + assert.Equal(t, "/mnt/data/project-new", got.SubstitutionContext.LocalWorkspaceFolder) + assert.Equal( + t, + "type=bind,source=/mnt/data/project-new,target=/home/coder/project-new", + got.SubstitutionContext.WorkspaceMount, + ) + assert.Equal(t, "/home/coder/project-new", got.MergedConfig.WorkspaceFolder) + require.NotNil(t, got.MergedConfig.WorkspaceMount) + assert.Equal( + t, + "type=bind,source=/mnt/data/project-new,target=/home/coder/project-new", + *got.MergedConfig.WorkspaceMount, + ) +} + +func TestUpdateWorkspaceResult_NestedPath(t *testing.T) { + setupTestPathManager(t) + + oldName := "repo" + newName := "repo-renamed" + + result := &devcontainerconfig.Result{ + SubstitutionContext: &devcontainerconfig.SubstitutionContext{ + ContainerWorkspaceFolder: "/workspaces/org/repo", + LocalWorkspaceFolder: "/home/user/dev/org/repo", + WorkspaceMount: "type=bind,source=/home/user/dev/org/repo,target=/workspaces/org/repo", + }, + MergedConfig: &devcontainerconfig.MergedDevContainerConfig{}, + } + result.MergedConfig.WorkspaceFolder = "/workspaces/org/repo" + result.MergedConfig.WorkspaceMount = ptrStr( + "type=bind,source=/home/user/dev/org/repo,target=/workspaces/org/repo", + ) + + writeWorkspaceResult(t, newName, result) + + devsyConfig := &config.Config{DefaultContext: testDefaultContext} + updateWorkspaceResult(devsyConfig, oldName, newName) + + got := loadWorkspaceResult(t, newName) + + assert.Equal( + t, + "/workspaces/org/repo-renamed", + got.SubstitutionContext.ContainerWorkspaceFolder, + ) + assert.Equal(t, "/home/user/dev/org/repo-renamed", got.SubstitutionContext.LocalWorkspaceFolder) + assert.Contains(t, got.SubstitutionContext.WorkspaceMount, "/workspaces/org/repo-renamed") + assert.Contains(t, got.SubstitutionContext.WorkspaceMount, "/home/user/dev/org/repo-renamed") + assert.Equal(t, "/workspaces/org/repo-renamed", got.MergedConfig.WorkspaceFolder) +} + +func TestUpdateWorkspaceResult_SameNameIdempotent(t *testing.T) { + setupTestPathManager(t) + + name := "my-ws" + + result := &devcontainerconfig.Result{ + SubstitutionContext: &devcontainerconfig.SubstitutionContext{ + ContainerWorkspaceFolder: "/workspaces/my-ws", + LocalWorkspaceFolder: "/home/user/my-ws", + WorkspaceMount: "type=bind,source=/home/user/my-ws,target=/workspaces/my-ws", + }, + MergedConfig: &devcontainerconfig.MergedDevContainerConfig{}, + } + result.MergedConfig.WorkspaceFolder = "/workspaces/my-ws" + result.MergedConfig.WorkspaceMount = ptrStr( + "type=bind,source=/home/user/my-ws,target=/workspaces/my-ws", + ) + + writeWorkspaceResult(t, name, result) + + devsyConfig := &config.Config{DefaultContext: testDefaultContext} + updateWorkspaceResult(devsyConfig, name, name) + + got := loadWorkspaceResult(t, name) + assert.Equal(t, "/workspaces/my-ws", got.SubstitutionContext.ContainerWorkspaceFolder) + assert.Equal(t, "/home/user/my-ws", got.SubstitutionContext.LocalWorkspaceFolder) + assert.Equal(t, + "type=bind,source=/home/user/my-ws,target=/workspaces/my-ws", + got.SubstitutionContext.WorkspaceMount, + ) + assert.Equal(t, "/workspaces/my-ws", got.MergedConfig.WorkspaceFolder) +} + +func TestUpdateWorkspaceResult_NilMergedConfig(t *testing.T) { + setupTestPathManager(t) + + oldName := "ws-old" + newName := "ws-new" + + result := &devcontainerconfig.Result{ + SubstitutionContext: &devcontainerconfig.SubstitutionContext{ + ContainerWorkspaceFolder: testContainerWSMount, + LocalWorkspaceFolder: "/home/user/ws-old", + WorkspaceMount: "type=bind,source=/home/user/ws-old,target=" + testContainerWSMount, + }, + MergedConfig: nil, + } + + writeWorkspaceResult(t, newName, result) + + devsyConfig := &config.Config{DefaultContext: testDefaultContext} + updateWorkspaceResult(devsyConfig, oldName, newName) + + got := loadWorkspaceResult(t, newName) + + assert.Equal(t, "/workspaces/ws-new", got.SubstitutionContext.ContainerWorkspaceFolder) + assert.Equal(t, "/home/user/ws-new", got.SubstitutionContext.LocalWorkspaceFolder) + assert.Contains(t, got.SubstitutionContext.WorkspaceMount, "/workspaces/ws-new") +} + +func TestUpdateWorkspaceResult_NilWorkspaceMount(t *testing.T) { + setupTestPathManager(t) + + oldName := "ws-old" + newName := "ws-new" + + result := &devcontainerconfig.Result{ + SubstitutionContext: &devcontainerconfig.SubstitutionContext{ + ContainerWorkspaceFolder: testContainerWSMount, + LocalWorkspaceFolder: "/home/user/ws-old", + WorkspaceMount: "type=bind,source=/home/user/ws-old,target=" + testContainerWSMount, + }, + MergedConfig: &devcontainerconfig.MergedDevContainerConfig{}, + } + result.MergedConfig.WorkspaceFolder = testContainerWSMount + result.MergedConfig.WorkspaceMount = nil + + writeWorkspaceResult(t, newName, result) + + devsyConfig := &config.Config{DefaultContext: testDefaultContext} + updateWorkspaceResult(devsyConfig, oldName, newName) + + got := loadWorkspaceResult(t, newName) + + assert.Equal(t, "/workspaces/ws-new", got.MergedConfig.WorkspaceFolder) + assert.Nil(t, got.MergedConfig.WorkspaceMount) +} + +func TestUpdateWorkspaceResult_NoResultFile(t *testing.T) { + setupTestPathManager(t) + + oldName := "nonexistent-old" + newName := "nonexistent-new" + + wsDir, err := provider.GetWorkspaceDir(testDefaultContext, newName) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(wsDir, 0o750)) + + devsyConfig := &config.Config{DefaultContext: testDefaultContext} + updateWorkspaceResult(devsyConfig, oldName, newName) + + _, err = os.Stat(filepath.Join(wsDir, "workspace_result.json")) + assert.True(t, os.IsNotExist(err), "should not create file when none exists") +} + +func TestUpdateWorkspaceResult_PreservesOtherFields(t *testing.T) { + setupTestPathManager(t) + + oldName := "myapp" + newName := "myapp-v2" + + result := &devcontainerconfig.Result{ + SubstitutionContext: &devcontainerconfig.SubstitutionContext{ + DevContainerID: "abc123", + ContainerWorkspaceFolder: "/workspaces/myapp", + LocalWorkspaceFolder: "/home/user/myapp", + WorkspaceMount: "type=bind,source=/home/user/myapp,target=/workspaces/myapp", + Env: map[string]string{"FOO": "bar"}, + }, + MergedConfig: &devcontainerconfig.MergedDevContainerConfig{}, + HostWarnings: []string{"some warning"}, + } + result.MergedConfig.WorkspaceFolder = "/workspaces/myapp" + + writeWorkspaceResult(t, newName, result) + + devsyConfig := &config.Config{DefaultContext: testDefaultContext} + updateWorkspaceResult(devsyConfig, oldName, newName) + + got := loadWorkspaceResult(t, newName) + + assert.Equal(t, "abc123", got.SubstitutionContext.DevContainerID) + assert.Equal(t, map[string]string{"FOO": "bar"}, got.SubstitutionContext.Env) + assert.Equal(t, []string{"some warning"}, got.HostWarnings) +} + +func TestUpdateWorkspaceResult_RawJSON(t *testing.T) { + setupTestPathManager(t) + + oldName := "old-ws" + newName := "new-ws" + + wsDir, err := provider.GetWorkspaceDir(testDefaultContext, newName) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(wsDir, 0o750)) + + rawJSON := `{ + "SubstitutionContext": { + "ContainerWorkspaceFolder": "/workspaces/old-ws", + "LocalWorkspaceFolder": "/home/user/old-ws", + "WorkspaceMount": "type=bind,source=/home/user/old-ws,target=/workspaces/old-ws" + }, + "MergedConfig": { + "workspaceFolder": "/workspaces/old-ws", + "workspaceMount": "type=bind,source=/home/user/old-ws,target=/workspaces/old-ws" + } +}` + + resultFile := filepath.Join(wsDir, "workspace_result.json") + require.NoError(t, os.WriteFile(resultFile, []byte(rawJSON), 0o600)) + + devsyConfig := &config.Config{DefaultContext: testDefaultContext} + updateWorkspaceResult(devsyConfig, oldName, newName) + + updatedBytes, err := os.ReadFile(resultFile) //nolint:gosec + require.NoError(t, err) + + var got devcontainerconfig.Result + require.NoError(t, json.Unmarshal(updatedBytes, &got)) + + assert.Equal(t, "/workspaces/new-ws", got.SubstitutionContext.ContainerWorkspaceFolder) + assert.Equal(t, "/home/user/new-ws", got.SubstitutionContext.LocalWorkspaceFolder) + assert.Equal(t, + "type=bind,source=/home/user/new-ws,target=/workspaces/new-ws", + got.SubstitutionContext.WorkspaceMount, + ) + assert.Equal(t, "/workspaces/new-ws", got.MergedConfig.WorkspaceFolder) + require.NotNil(t, got.MergedConfig.WorkspaceMount) + assert.Equal(t, + "type=bind,source=/home/user/new-ws,target=/workspaces/new-ws", + *got.MergedConfig.WorkspaceMount, + ) +} diff --git a/pkg/workspace/rename_test.go b/pkg/workspace/rename_test.go new file mode 100644 index 000000000..f084e9b68 --- /dev/null +++ b/pkg/workspace/rename_test.go @@ -0,0 +1,296 @@ +package workspace + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + testContainerNewWS = "/workspaces/new-ws" + testLocalNewWS = "/home/user/new-ws" + testContainerNew = "/workspaces/new" + testContainerOldWS = "/workspaces/old-ws" + testLocalOldWS = "/home/user/old-ws" + testContainerApp = "/workspaces/app" + testContainerOld = "/workspaces/old" +) + +func TestNewPathReplacer_DefaultWorkspaceDir(t *testing.T) { + r := newPathReplacer(testContainerOldWS, testLocalOldWS, "old-ws", "new-ws") + + expected := [][2]string{ + {testContainerOldWS, testContainerNewWS}, + {testLocalOldWS, testLocalNewWS}, + } + + assert.NotNil(t, r) + assert.Equal(t, expected, r.pairs) + assert.False(t, r.changed) +} + +func TestNewPathReplacer_NonDefaultWorkspaceDir(t *testing.T) { + r := newPathReplacer("/home/user/project", "/mnt/data/project", "project", "renamed") + + expected := [][2]string{ + {"/home/user/project", "/home/user/renamed"}, + {"/mnt/data/project", "/mnt/data/renamed"}, + } + + assert.Equal(t, expected, r.pairs) + assert.False(t, r.changed) +} + +func TestNewPathReplacer_NestedWorkspacePath(t *testing.T) { + r := newPathReplacer( + "/workspace/dev/projects/my-app", + "/home/user/workspace/dev/projects/my-app", + "my-app", + "my-app-v2", + ) + + expected := [][2]string{ + {"/workspace/dev/projects/my-app", "/workspace/dev/projects/my-app-v2"}, + { + "/home/user/workspace/dev/projects/my-app", + "/home/user/workspace/dev/projects/my-app-v2", + }, + } + + assert.Equal(t, expected, r.pairs) +} + +func TestNewPathReplacer_EmptyContainerFolder(t *testing.T) { + r := newPathReplacer("", testLocalOldWS, "old-ws", "new-ws") + + expected := [][2]string{ + {testLocalOldWS, testLocalNewWS}, + } + + assert.Equal(t, expected, r.pairs) +} + +func TestNewPathReplacer_EmptyLocalFolder(t *testing.T) { + r := newPathReplacer(testContainerOldWS, "", "old-ws", "new-ws") + + expected := [][2]string{ + {testContainerOldWS, testContainerNewWS}, + } + + assert.Equal(t, expected, r.pairs) +} + +func TestNewPathReplacer_BothEmpty(t *testing.T) { + r := newPathReplacer("", "", "old-ws", "new-ws") + + assert.Nil(t, r.pairs) +} + +func TestNewPathReplacer_SpecialCharacters(t *testing.T) { + r := newPathReplacer( + "/workspaces/my-app_v1.0", + "/home/user/my-app_v1.0", + "my-app_v1.0", + "my-app_v2.0", + ) + + expected := [][2]string{ + {"/workspaces/my-app_v1.0", "/workspaces/my-app_v2.0"}, + {"/home/user/my-app_v1.0", "/home/user/my-app_v2.0"}, + } + + assert.Equal(t, expected, r.pairs) +} + +func TestPathReplacer_Replace_BasicReplacement(t *testing.T) { + r := &pathReplacer{ + pairs: [][2]string{ + {testContainerOldWS, testContainerNewWS}, + }, + } + + output := r.replace("/workspaces/old-ws/src/main.go") + + assert.Equal(t, "/workspaces/new-ws/src/main.go", output) + assert.True(t, r.changed) +} + +func TestPathReplacer_Replace_NoMatch(t *testing.T) { + r := &pathReplacer{ + pairs: [][2]string{ + {testContainerOldWS, testContainerNewWS}, + }, + } + + output := r.replace("/workspaces/other-ws/src/main.go") + + assert.Equal(t, "/workspaces/other-ws/src/main.go", output) + assert.False(t, r.changed) +} + +func TestPathReplacer_Replace_MultipleReplacements(t *testing.T) { + r := &pathReplacer{ + pairs: [][2]string{ + {testContainerOldWS, testContainerNewWS}, + {testLocalOldWS, testLocalNewWS}, + }, + } + + input := "source=/home/user/old-ws,target=/workspaces/old-ws,type=bind" + expected := "source=/home/user/new-ws,target=/workspaces/new-ws,type=bind" + + output := r.replace(input) + + assert.Equal(t, expected, output) + assert.True(t, r.changed) +} + +func TestPathReplacer_Replace_EmptyString(t *testing.T) { + r := &pathReplacer{ + pairs: [][2]string{ + {testContainerOldWS, testContainerNewWS}, + }, + } + + output := r.replace("") + + assert.Equal(t, "", output) + assert.False(t, r.changed) +} + +func TestPathReplacer_Replace_MultipleOccurrences(t *testing.T) { + r := &pathReplacer{ + pairs: [][2]string{ + {testContainerApp, "/workspaces/app-new"}, + }, + } + + input := testContainerApp + "/go.mod " + testContainerApp + "/go.sum" + expected := "/workspaces/app-new/go.mod /workspaces/app-new/go.sum" + + output := r.replace(input) + + assert.Equal(t, expected, output) + assert.True(t, r.changed) +} + +func TestPathReplacer_Replace_PartialMatch(t *testing.T) { + r := &pathReplacer{ + pairs: [][2]string{ + {testContainerApp, "/workspaces/app-new"}, + }, + } + + input := "/workspaces/application/src" + expected := "/workspaces/app-newlication/src" + + output := r.replace(input) + + assert.Equal(t, expected, output) + assert.True(t, r.changed) +} + +func TestPathReplacer_Replace_NoPairs(t *testing.T) { + r := &pathReplacer{ + pairs: nil, + } + + output := r.replace("/workspaces/old-ws/src/main.go") + + assert.Equal(t, "/workspaces/old-ws/src/main.go", output) + assert.False(t, r.changed) +} + +func TestPathReplacer_Replace_WorkspaceMount(t *testing.T) { + r := &pathReplacer{ + pairs: [][2]string{ + {testContainerOldWS, testContainerNewWS}, + {testLocalOldWS, testLocalNewWS}, + }, + } + + input := "type=bind,source=/home/user/old-ws,target=/workspaces/old-ws" + expected := "type=bind,source=/home/user/new-ws,target=/workspaces/new-ws" + + output := r.replace(input) + + assert.Equal(t, expected, output) + assert.True(t, r.changed) +} + +func TestPathReplacer_ReplaceMultipleCalls(t *testing.T) { + r := &pathReplacer{ + pairs: [][2]string{ + {testContainerOld, testContainerNew}, + }, + } + + r.replace("/workspaces/other") + assert.False(t, r.changed) + + r.replace(testContainerOld) + assert.True(t, r.changed) + + r.replace("/workspaces/other") + assert.True(t, r.changed, "changed flag should remain true once set") +} + +func TestPathReplacer_ReplacePairOrder(t *testing.T) { + r := &pathReplacer{ + pairs: [][2]string{ + {testContainerOld, testContainerNew}, + {"/home/old", "/home/new"}, + {"/mnt/old", "/mnt/new"}, + }, + } + + input := testContainerOld + " /home/old /mnt/old" + expected := testContainerNew + " /home/new /mnt/new" + + output := r.replace(input) + + assert.Equal(t, expected, output) + assert.True(t, r.changed) +} + +func TestPathReplacer_ReplaceWithTrailingSlash(t *testing.T) { + tests := []struct { + name string + pairs [][2]string + input string + expected string + }{ + { + name: "no trailing slash in pair or input", + pairs: [][2]string{ + {testContainerOld, testContainerNew}, + }, + input: testContainerOld, + expected: testContainerNew, + }, + { + name: "trailing slash in input only", + pairs: [][2]string{ + {testContainerOld, testContainerNew}, + }, + input: testContainerOld + "/", + expected: testContainerNew + "/", + }, + { + name: "subpath match", + pairs: [][2]string{ + {testContainerOld, testContainerNew}, + }, + input: testContainerOld + "/subdir/file.txt", + expected: testContainerNew + "/subdir/file.txt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &pathReplacer{pairs: tt.pairs} + output := r.replace(tt.input) + assert.Equal(t, tt.expected, output) + }) + } +}