WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)
1import { beforeEach, describe, expect, it, vi } from 'vitest';
2import { createConfigCommand } from '../../src/commands/config.js';
3
4// Mock modules
5vi.mock('node:fs/promises');
6vi.mock('simple-git');
7vi.mock('../../src/lib/config.js');
8
9// Import mocked modules
10import * as fs from 'node:fs/promises';
11import { simpleGit } from 'simple-git';
12import * as configModule from '../../src/lib/config.js';
13
14describe('Config Command', () => {
15 let consoleLogSpy: ReturnType<typeof vi.fn>;
16 let consoleErrorSpy: ReturnType<typeof vi.fn>;
17
18 beforeEach(() => {
19 vi.clearAllMocks();
20
21 // Mock console methods
22 consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never;
23 consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never;
24
25 // Mock process.exit to throw so tests don't actually exit
26 vi.spyOn(process, 'exit').mockImplementation((code) => {
27 throw new Error(`process.exit(${code})`);
28 }) as never;
29 });
30
31 describe('list command', () => {
32 it('should list all available config keys with descriptions', async () => {
33 vi.mocked(configModule.loadConfig).mockResolvedValue({ remote: 'origin' });
34
35 const config = createConfigCommand();
36 await config.parseAsync(['node', 'test', 'list']);
37
38 expect(consoleLogSpy).toHaveBeenCalledWith('Available configuration keys:\n');
39 expect(consoleLogSpy).toHaveBeenCalledWith(' remote');
40 expect(consoleLogSpy).toHaveBeenCalledWith(
41 expect.stringContaining('Default Git remote to use')
42 );
43 expect(consoleLogSpy).toHaveBeenCalledWith(
44 expect.stringContaining('Current value: "origin"')
45 );
46 });
47
48 it('should show "(not set)" for unset keys', async () => {
49 vi.mocked(configModule.loadConfig).mockResolvedValue({});
50
51 const config = createConfigCommand();
52 await config.parseAsync(['node', 'test', 'list']);
53
54 expect(consoleLogSpy).toHaveBeenCalledWith(
55 expect.stringContaining('Current value: (not set)')
56 );
57 });
58 });
59
60 describe('get command', () => {
61 it('should show all config values when no key specified', async () => {
62 vi.mocked(configModule.loadConfig).mockResolvedValue({ remote: 'origin' });
63
64 const config = createConfigCommand();
65 await config.parseAsync(['node', 'test', 'get']);
66
67 expect(consoleLogSpy).toHaveBeenCalledWith('remote = origin');
68 });
69
70 it('should show "No configuration set" when config is empty', async () => {
71 vi.mocked(configModule.loadConfig).mockResolvedValue({});
72
73 const config = createConfigCommand();
74 await config.parseAsync(['node', 'test', 'get']);
75
76 expect(consoleLogSpy).toHaveBeenCalledWith('No configuration set');
77 });
78
79 it('should show specific key value', async () => {
80 vi.mocked(configModule.loadConfig).mockResolvedValue({ remote: 'upstream' });
81
82 const config = createConfigCommand();
83 await config.parseAsync(['node', 'test', 'get', 'remote']);
84
85 expect(consoleLogSpy).toHaveBeenCalledWith('remote = upstream');
86 });
87
88 it('should show "(not set)" for undefined key', async () => {
89 vi.mocked(configModule.loadConfig).mockResolvedValue({});
90
91 const config = createConfigCommand();
92 await config.parseAsync(['node', 'test', 'get', 'remote']);
93
94 expect(consoleLogSpy).toHaveBeenCalledWith('remote = (not set)');
95 });
96 });
97
98 describe('set command', () => {
99 it('should set local config value', async () => {
100 const mockGit = {
101 checkIsRepo: vi.fn().mockResolvedValue(true),
102 revparse: vi.fn().mockResolvedValue('/test/repo\n'),
103 };
104 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
105 vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
106 vi.mocked(fs.mkdir).mockResolvedValue(undefined);
107 vi.mocked(fs.writeFile).mockResolvedValue(undefined);
108
109 const config = createConfigCommand();
110 await config.parseAsync(['node', 'test', 'set', 'remote', 'origin']);
111
112 expect(fs.writeFile).toHaveBeenCalledWith(
113 '/test/repo/.tangledrc',
114 `${JSON.stringify({ remote: 'origin' }, null, 2)}\n`,
115 'utf-8'
116 );
117 expect(consoleLogSpy).toHaveBeenCalledWith(
118 expect.stringContaining('Set remote to "origin" in local config')
119 );
120 });
121
122 it('should set global config value with --global flag', async () => {
123 vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
124 vi.mocked(fs.mkdir).mockResolvedValue(undefined);
125 vi.mocked(fs.writeFile).mockResolvedValue(undefined);
126
127 const config = createConfigCommand();
128 await config.parseAsync(['node', 'test', 'set', 'remote', 'origin', '--global']);
129
130 expect(fs.writeFile).toHaveBeenCalledWith(
131 expect.stringContaining('.tangledrc'),
132 `${JSON.stringify({ remote: 'origin' }, null, 2)}\n`,
133 'utf-8'
134 );
135 expect(consoleLogSpy).toHaveBeenCalledWith(
136 expect.stringContaining('Set remote to "origin" in user config')
137 );
138 });
139
140 it('should error when not in Git repo for local config', async () => {
141 const mockGit = {
142 checkIsRepo: vi.fn().mockResolvedValue(false),
143 };
144 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
145
146 const config = createConfigCommand();
147 await expect(config.parseAsync(['node', 'test', 'set', 'remote', 'origin'])).rejects.toThrow(
148 'process.exit'
149 );
150
151 expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to set config'));
152 });
153
154 it('should preserve existing config values', async () => {
155 const mockGit = {
156 checkIsRepo: vi.fn().mockResolvedValue(true),
157 revparse: vi.fn().mockResolvedValue('/test/repo\n'),
158 };
159 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
160 vi.mocked(fs.readFile).mockResolvedValue(
161 JSON.stringify({ remote: 'origin', other: 'value' })
162 );
163 vi.mocked(fs.mkdir).mockResolvedValue(undefined);
164 vi.mocked(fs.writeFile).mockResolvedValue(undefined);
165
166 const config = createConfigCommand();
167 await config.parseAsync(['node', 'test', 'set', 'remote', 'upstream']);
168
169 expect(fs.writeFile).toHaveBeenCalledWith(
170 '/test/repo/.tangledrc',
171 `${JSON.stringify({ remote: 'upstream', other: 'value' }, null, 2)}\n`,
172 'utf-8'
173 );
174 });
175 });
176
177 describe('unset command', () => {
178 it('should unset local config value', async () => {
179 const mockGit = {
180 checkIsRepo: vi.fn().mockResolvedValue(true),
181 revparse: vi.fn().mockResolvedValue('/test/repo\n'),
182 };
183 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
184 vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ remote: 'origin' }));
185 vi.mocked(fs.unlink).mockResolvedValue(undefined);
186
187 const config = createConfigCommand();
188 await config.parseAsync(['node', 'test', 'unset', 'remote']);
189
190 expect(fs.unlink).toHaveBeenCalledWith('/test/repo/.tangledrc');
191 expect(consoleLogSpy).toHaveBeenCalledWith(
192 expect.stringContaining('Cleared remote from local config')
193 );
194 });
195
196 it('should unset global config value with --global flag', async () => {
197 vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ remote: 'origin' }));
198 vi.mocked(fs.unlink).mockResolvedValue(undefined);
199
200 const config = createConfigCommand();
201 await config.parseAsync(['node', 'test', 'unset', 'remote', '--global']);
202
203 expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('.tangledrc'));
204 expect(consoleLogSpy).toHaveBeenCalledWith(
205 expect.stringContaining('Cleared remote from user config')
206 );
207 });
208
209 it('should delete config file when last key is removed', async () => {
210 const mockGit = {
211 checkIsRepo: vi.fn().mockResolvedValue(true),
212 revparse: vi.fn().mockResolvedValue('/test/repo\n'),
213 };
214 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
215 vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ remote: 'origin' }));
216 vi.mocked(fs.unlink).mockResolvedValue(undefined);
217
218 const config = createConfigCommand();
219 await config.parseAsync(['node', 'test', 'unset', 'remote']);
220
221 expect(fs.unlink).toHaveBeenCalledWith('/test/repo/.tangledrc');
222 });
223
224 it('should preserve other config values when unsetting one key', async () => {
225 const mockGit = {
226 checkIsRepo: vi.fn().mockResolvedValue(true),
227 revparse: vi.fn().mockResolvedValue('/test/repo\n'),
228 };
229 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
230 vi.mocked(fs.readFile).mockResolvedValue(
231 JSON.stringify({ remote: 'origin', other: 'value' })
232 );
233 vi.mocked(fs.writeFile).mockResolvedValue(undefined);
234
235 const config = createConfigCommand();
236 await config.parseAsync(['node', 'test', 'unset', 'remote']);
237
238 expect(fs.writeFile).toHaveBeenCalledWith(
239 '/test/repo/.tangledrc',
240 `${JSON.stringify({ other: 'value' }, null, 2)}\n`,
241 'utf-8'
242 );
243 });
244
245 it('should handle unset when config does not exist', async () => {
246 const mockGit = {
247 checkIsRepo: vi.fn().mockResolvedValue(true),
248 revparse: vi.fn().mockResolvedValue('/test/repo\n'),
249 };
250 vi.mocked(simpleGit).mockReturnValue(mockGit as never);
251 vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
252
253 const config = createConfigCommand();
254 await config.parseAsync(['node', 'test', 'unset', 'remote']);
255
256 expect(consoleLogSpy).toHaveBeenCalledWith(
257 expect.stringContaining('Cleared remote from local config')
258 );
259 });
260 });
261});