Skip to content

Commit 737bad6

Browse files
authored
Use yaml as default custom modes format (#3749)
1 parent 7368fa9 commit 737bad6

File tree

7 files changed

+314
-106
lines changed

7 files changed

+314
-106
lines changed

.changeset/gold-meals-tell.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"roo-cline": patch
3+
---
4+
5+
Use YAML as default custom modes format

src/__tests__/migrateSettings.test.ts

Lines changed: 165 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ import { migrateSettings } from "../utils/migrateSettings"
77

88
// Mock dependencies
99
jest.mock("vscode")
10-
jest.mock("fs/promises")
10+
jest.mock("fs/promises", () => ({
11+
mkdir: jest.fn().mockResolvedValue(undefined),
12+
readFile: jest.fn(),
13+
writeFile: jest.fn().mockResolvedValue(undefined),
14+
rename: jest.fn().mockResolvedValue(undefined),
15+
unlink: jest.fn().mockResolvedValue(undefined),
16+
}))
1117
jest.mock("fs")
1218
jest.mock("../utils/fs")
1319

@@ -18,11 +24,12 @@ describe("Settings Migration", () => {
1824
const mockSettingsDir = path.join(mockStoragePath, "settings")
1925

2026
// Legacy file names
21-
const legacyCustomModesPath = path.join(mockSettingsDir, "cline_custom_modes.json")
27+
const legacyCustomModesJson = path.join(mockSettingsDir, "custom_modes.json")
28+
const legacyClineCustomModesPath = path.join(mockSettingsDir, "cline_custom_modes.json")
2229
const legacyMcpSettingsPath = path.join(mockSettingsDir, "cline_mcp_settings.json")
2330

2431
// New file names
25-
const newCustomModesPath = path.join(mockSettingsDir, GlobalFileNames.customModes)
32+
const newCustomModesYaml = path.join(mockSettingsDir, GlobalFileNames.customModes)
2633
const newMcpSettingsPath = path.join(mockSettingsDir, GlobalFileNames.mcpSettings)
2734

2835
beforeEach(() => {
@@ -43,66 +50,83 @@ describe("Settings Migration", () => {
4350
globalStorageUri: { fsPath: mockStoragePath },
4451
} as unknown as vscode.ExtensionContext
4552

46-
// The fs/promises mock is already set up in src/__mocks__/fs/promises.ts
47-
// We don't need to manually mock these methods
48-
4953
// Set global outputChannel for all tests
5054
;(global as any).outputChannel = mockOutputChannel
5155
})
5256

5357
it("should migrate custom modes file if old file exists and new file doesn't", async () => {
54-
// Mock file existence checks
58+
// Clear all previous mocks to ensure clean test environment
59+
jest.clearAllMocks()
60+
61+
// Setup mock for rename function
62+
const mockRename = (fs.rename as jest.Mock).mockResolvedValue(undefined)
63+
64+
// Mock file existence checks - only return true for paths we want to exist
5565
;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
5666
if (path === mockSettingsDir) return true
57-
if (path === legacyCustomModesPath) return true
58-
if (path === newCustomModesPath) return false
59-
return false
67+
if (path === legacyClineCustomModesPath) return true
68+
return false // All other paths don't exist, including destination files
6069
})
6170

71+
// Run the migration
6272
await migrateSettings(mockContext, mockOutputChannel)
6373

64-
// Verify file was renamed
65-
expect(fs.rename).toHaveBeenCalledWith(legacyCustomModesPath, newCustomModesPath)
74+
// Verify expected rename call - cline_custom_modes.json should be renamed to custom_modes.json
75+
expect(mockRename).toHaveBeenCalledWith(legacyClineCustomModesPath, legacyCustomModesJson)
6676
})
6777

6878
it("should migrate MCP settings file if old file exists and new file doesn't", async () => {
69-
// Mock file existence checks
79+
// Clear all previous mocks to ensure clean test environment
80+
jest.clearAllMocks()
81+
82+
// Setup mock for rename function
83+
const mockRename = (fs.rename as jest.Mock).mockResolvedValue(undefined)
84+
85+
// Ensure the other files don't interfere with this test
7086
;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
7187
if (path === mockSettingsDir) return true
7288
if (path === legacyMcpSettingsPath) return true
73-
if (path === newMcpSettingsPath) return false
74-
return false
89+
if (path === legacyClineCustomModesPath) return false // Ensure this file doesn't exist
90+
if (path === legacyCustomModesJson) return false // Ensure this file doesn't exist
91+
return false // All other paths don't exist, including destination files
7592
})
7693

94+
// Run the migration
7795
await migrateSettings(mockContext, mockOutputChannel)
7896

79-
// Verify file was renamed
80-
expect(fs.rename).toHaveBeenCalledWith(legacyMcpSettingsPath, newMcpSettingsPath)
97+
// Verify expected rename call
98+
expect(mockRename).toHaveBeenCalledWith(legacyMcpSettingsPath, newMcpSettingsPath)
8199
})
82100

83101
it("should not migrate if new file already exists", async () => {
84-
// Mock file existence checks
102+
// Clear all previous mocks to ensure clean test environment
103+
jest.clearAllMocks()
104+
105+
// Setup mock for rename function
106+
const mockRename = (fs.rename as jest.Mock).mockResolvedValue(undefined)
107+
108+
// Mock file existence checks - both source and destination exist
85109
;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
86110
if (path === mockSettingsDir) return true
87-
if (path === legacyCustomModesPath) return true
88-
if (path === newCustomModesPath) return true
111+
if (path === legacyClineCustomModesPath) return true
112+
if (path === legacyCustomModesJson) return true // Destination already exists
89113
if (path === legacyMcpSettingsPath) return true
90114
if (path === newMcpSettingsPath) return true
91115
return false
92116
})
93117

94118
await migrateSettings(mockContext, mockOutputChannel)
95119

96-
// Verify no files were renamed
97-
expect(fs.rename).not.toHaveBeenCalled()
120+
// Verify rename was not called since destination files exist
121+
expect(mockRename).not.toHaveBeenCalled()
98122
})
99123

100124
it("should handle errors gracefully", async () => {
101-
// Mock file existence checks to throw an error
102-
;(fileExistsAtPath as jest.Mock).mockRejectedValue(new Error("Test error"))
125+
// Clear mocks
126+
jest.clearAllMocks()
103127

104-
// Set the global outputChannel for the test
105-
;(global as any).outputChannel = mockOutputChannel
128+
// Mock file existence to throw error
129+
;(fileExistsAtPath as jest.Mock).mockRejectedValue(new Error("Test error"))
106130

107131
await migrateSettings(mockContext, mockOutputChannel)
108132

@@ -111,4 +135,119 @@ describe("Settings Migration", () => {
111135
expect.stringContaining("Error migrating settings files"),
112136
)
113137
})
138+
139+
it("should convert custom_modes.json to YAML format", async () => {
140+
// Clear all previous mocks to ensure clean test environment
141+
jest.clearAllMocks()
142+
143+
const testJsonContent = JSON.stringify({ customModes: [{ slug: "test-mode", name: "Test Mode" }] })
144+
145+
// Setup mock functions
146+
const mockWrite = (fs.writeFile as jest.Mock).mockResolvedValue(undefined)
147+
const mockUnlink = (fs.unlink as jest.Mock).mockResolvedValue(undefined)
148+
149+
// Mock file read to return JSON content
150+
;(fs.readFile as jest.Mock).mockImplementation(async (path: any) => {
151+
if (path === legacyCustomModesJson) {
152+
return testJsonContent
153+
}
154+
throw new Error("File not found: " + path)
155+
})
156+
157+
// Isolate this test by making sure only the specific JSON file exists
158+
;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
159+
if (path === mockSettingsDir) return true
160+
if (path === legacyCustomModesJson) return true
161+
if (path === legacyClineCustomModesPath) return false
162+
if (path === legacyMcpSettingsPath) return false
163+
return false
164+
})
165+
166+
await migrateSettings(mockContext, mockOutputChannel)
167+
168+
// Verify file operations
169+
expect(mockWrite).toHaveBeenCalledWith(newCustomModesYaml, expect.any(String), "utf-8")
170+
// We don't delete the original JSON file to allow for rollback
171+
expect(mockUnlink).not.toHaveBeenCalled()
172+
173+
// Verify log message mentions preservation of original file
174+
expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(
175+
expect.stringContaining("original JSON file preserved for rollback purposes"),
176+
)
177+
})
178+
179+
it("should handle corrupt JSON gracefully", async () => {
180+
// Clear all previous mocks to ensure clean test environment
181+
jest.clearAllMocks()
182+
183+
// Setup mock functions
184+
const mockWrite = (fs.writeFile as jest.Mock).mockResolvedValue(undefined)
185+
const mockUnlink = (fs.unlink as jest.Mock).mockResolvedValue(undefined)
186+
187+
// Mock file read to return corrupt JSON
188+
;(fs.readFile as jest.Mock).mockImplementation(async (path: any) => {
189+
if (path === legacyCustomModesJson) {
190+
return "{ invalid json content" // This will cause an error when parsed
191+
}
192+
throw new Error("File not found: " + path)
193+
})
194+
195+
// Isolate this test
196+
;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
197+
if (path === mockSettingsDir) return true
198+
if (path === legacyCustomModesJson) return true
199+
if (path === legacyClineCustomModesPath) return false
200+
if (path === legacyMcpSettingsPath) return false
201+
return false
202+
})
203+
204+
await migrateSettings(mockContext, mockOutputChannel)
205+
206+
// Verify error was logged
207+
expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(
208+
expect.stringContaining("Error parsing custom_modes.json"),
209+
)
210+
211+
// Verify no write/unlink operations were performed
212+
expect(mockWrite).not.toHaveBeenCalled()
213+
expect(mockUnlink).not.toHaveBeenCalled()
214+
})
215+
216+
it("should skip migration when YAML file already exists", async () => {
217+
// Clear all previous mocks to ensure clean test environment
218+
jest.clearAllMocks()
219+
220+
// Setup mock functions
221+
const mockWrite = (fs.writeFile as jest.Mock).mockResolvedValue(undefined)
222+
const mockUnlink = (fs.unlink as jest.Mock).mockResolvedValue(undefined)
223+
224+
// Mock file read
225+
;(fs.readFile as jest.Mock).mockImplementation(async (path: any) => {
226+
if (path === legacyCustomModesJson) {
227+
return JSON.stringify({ customModes: [] })
228+
}
229+
throw new Error("File not found: " + path)
230+
})
231+
232+
// Mock file existence checks - both source and yaml destination exist
233+
;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
234+
if (path === mockSettingsDir) return true
235+
if (path === legacyCustomModesJson) return true
236+
if (path === newCustomModesYaml) return true // YAML already exists
237+
if (path === legacyClineCustomModesPath) return false
238+
if (path === legacyMcpSettingsPath) return false
239+
return false
240+
})
241+
242+
await migrateSettings(mockContext, mockOutputChannel)
243+
244+
// Verify skip message was logged
245+
expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(
246+
"custom_modes.yaml already exists, skipping migration",
247+
)
248+
249+
// Verify no file operations occurred
250+
expect(mockWrite).not.toHaveBeenCalled()
251+
expect(mockUnlink).not.toHaveBeenCalled()
252+
})
114253
})

src/core/config/CustomModesManager.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export class CustomModesManager {
120120
const fileExists = await fileExistsAtPath(filePath)
121121

122122
if (!fileExists) {
123-
await this.queueWrite(() => fs.writeFile(filePath, JSON.stringify({ customModes: [] }, null, 2)))
123+
await this.queueWrite(() => fs.writeFile(filePath, yaml.stringify({ customModes: [] })))
124124
}
125125

126126
return filePath
@@ -136,7 +136,7 @@ export class CustomModesManager {
136136
const content = await fs.readFile(settingsPath, "utf-8")
137137

138138
const errorMessage =
139-
"Invalid custom modes format. Please ensure your settings follow the correct JSON format."
139+
"Invalid custom modes format. Please ensure your settings follow the correct YAML format."
140140

141141
let config: any
142142

@@ -291,20 +291,20 @@ export class CustomModesManager {
291291
content = await fs.readFile(filePath, "utf-8")
292292
} catch (error) {
293293
// File might not exist yet.
294-
content = JSON.stringify({ customModes: [] })
294+
content = yaml.stringify({ customModes: [] })
295295
}
296296

297297
let settings
298298

299299
try {
300300
settings = yaml.parse(content)
301301
} catch (error) {
302-
console.error(`[CustomModesManager] Failed to parse JSON from ${filePath}:`, error)
302+
console.error(`[CustomModesManager] Failed to parse YAML from ${filePath}:`, error)
303303
settings = { customModes: [] }
304304
}
305305

306306
settings.customModes = operation(settings.customModes || [])
307-
await fs.writeFile(filePath, JSON.stringify(settings, null, 2), "utf-8")
307+
await fs.writeFile(filePath, yaml.stringify(settings), "utf-8")
308308
}
309309

310310
private async refreshMergedState(): Promise<void> {
@@ -369,7 +369,7 @@ export class CustomModesManager {
369369
public async resetCustomModes(): Promise<void> {
370370
try {
371371
const filePath = await this.getCustomModesFilePath()
372-
await fs.writeFile(filePath, JSON.stringify({ customModes: [] }, null, 2))
372+
await fs.writeFile(filePath, yaml.stringify({ customModes: [] }))
373373
await this.context.globalState.update("customModes", [])
374374
this.clearCache()
375375
await this.onUpdate()

0 commit comments

Comments
 (0)