package cmd import ( "os" "path/filepath" "testing" "time" "github.com/warrenronsiek/ctask/internal/workspace" ) // createTestWs creates a minimal workspace for delete testing. func createTestWs(t *testing.T, root, category, dirName, status string) string { t.Helper() dir := filepath.Join(root, category, dirName) os.MkdirAll(dir, 0755) os.MkdirAll(filepath.Join(dir, "context"), 0755) os.MkdirAll(filepath.Join(dir, "output"), 0755) os.MkdirAll(filepath.Join(dir, "logs"), 0755) now := time.Now().UTC().Truncate(time.Second) slug := dirName[11:] // skip "YYYY-MM-DD_" meta := &workspace.TaskMeta{ ID: "test", Slug: slug, Title: slug, CreatedAt: now, UpdatedAt: now, Status: status, Category: category, Mode: "local", Agent: "claude", WorkspacePath: dir, } workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta) // Write seed files os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte("test claude"), 0644) os.WriteFile(filepath.Join(dir, "notes.md"), []byte("test notes"), 0644) return dir } func TestActiveSessionManifestBlocksDelete(t *testing.T) { root := t.TempDir() wsDir := createTestWs(t, root, "general", "2026-04-06_active-ws", "active") // Simulate an active session by creating .ctask/manifest-start.json ctaskDir := filepath.Join(wsDir, ".ctask") os.MkdirAll(ctaskDir, 0755) os.WriteFile(filepath.Join(ctaskDir, "manifest-start.json"), []byte(`{"captured_at":"2026-04-06T00:00:00Z","files":[]}`), 0644) // Verify manifest exists manifestPath := filepath.Join(wsDir, ".ctask", "manifest-start.json") if _, err := os.Stat(manifestPath); os.IsNotExist(err) { t.Fatal("manifest should exist for this test") } // Verify all workspace files still exist for _, name := range []string{"task.yaml", "CLAUDE.md", "notes.md"} { p := filepath.Join(wsDir, name) if _, err := os.Stat(p); os.IsNotExist(err) { t.Errorf("pre-check: file %s should exist", name) } } } func TestNoFileMutationOnActiveSessionRefusal(t *testing.T) { root := t.TempDir() wsDir := createTestWs(t, root, "general", "2026-04-06_protected-ws", "active") // Add extra files to verify nothing gets touched os.WriteFile(filepath.Join(wsDir, "output", "artifact.txt"), []byte("important data"), 0644) os.WriteFile(filepath.Join(wsDir, "context", "reference.md"), []byte("reference"), 0644) // Simulate active session ctaskDir := filepath.Join(wsDir, ".ctask") os.MkdirAll(ctaskDir, 0755) os.WriteFile(filepath.Join(ctaskDir, "manifest-start.json"), []byte(`{"captured_at":"2026-04-06T00:00:00Z","files":[]}`), 0644) // Record all files before the (would-be) delete attempt var filesBefore []string filepath.Walk(wsDir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } rel, _ := filepath.Rel(wsDir, path) filesBefore = append(filesBefore, rel) return nil }) // The delete command would check for manifest and refuse. // We verify the check logic directly: manifestPath := filepath.Join(wsDir, ".ctask", "manifest-start.json") _, manifestErr := os.Stat(manifestPath) if manifestErr != nil { t.Fatal("manifest should exist, blocking delete") } // Verify ALL files still exist after the protection check var filesAfter []string filepath.Walk(wsDir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } rel, _ := filepath.Rel(wsDir, path) filesAfter = append(filesAfter, rel) return nil }) if len(filesBefore) != len(filesAfter) { t.Errorf("file count changed: before=%d, after=%d", len(filesBefore), len(filesAfter)) } // Verify specific important files for _, name := range []string{"task.yaml", "CLAUDE.md", "notes.md", "output/artifact.txt", "context/reference.md"} { p := filepath.Join(wsDir, name) if _, err := os.Stat(p); os.IsNotExist(err) { t.Errorf("file %s was deleted/modified during protection check", name) } } } func TestInactiveWorkspaceCanBeDeleted(t *testing.T) { root := t.TempDir() wsDir := createTestWs(t, root, "general", "2026-04-06_deletable-ws", "active") // No manifest = no active session manifestPath := filepath.Join(wsDir, ".ctask", "manifest-start.json") if _, err := os.Stat(manifestPath); err == nil { t.Fatal("manifest should NOT exist for this test") } // Verify the workspace exists if _, err := os.Stat(wsDir); os.IsNotExist(err) { t.Fatal("workspace should exist before delete") } // Simulate deletion (what the command does after all checks pass) if err := os.RemoveAll(wsDir); err != nil { t.Fatalf("delete failed: %v", err) } // Verify it's gone if _, err := os.Stat(wsDir); !os.IsNotExist(err) { t.Error("workspace should be deleted") } } func TestEnvVarAlsoBlocksDelete(t *testing.T) { root := t.TempDir() wsDir := createTestWs(t, root, "general", "2026-04-06_envvar-ws", "active") // Set CTASK_WORKSPACE to match os.Setenv("CTASK_WORKSPACE", wsDir) defer os.Unsetenv("CTASK_WORKSPACE") absTarget, _ := filepath.Abs(wsDir) absActive, _ := filepath.Abs(os.Getenv("CTASK_WORKSPACE")) // Verify the env var check would match if absTarget != absActive { t.Fatalf("paths should match: %q vs %q", absTarget, absActive) } // Verify files are untouched for _, name := range []string{"task.yaml", "CLAUDE.md", "notes.md"} { p := filepath.Join(wsDir, name) if _, err := os.Stat(p); os.IsNotExist(err) { t.Errorf("file %s should still exist", name) } } }