@tanagram/cli 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,389 @@
1
+ package commands
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "path/filepath"
7
+ "testing"
8
+ )
9
+
10
+ func TestConfigClaude(t *testing.T) {
11
+ // Create a temporary directory
12
+ tempDir, err := os.MkdirTemp("", "tanagram-config-test-*")
13
+ if err != nil {
14
+ t.Fatalf("Failed to create temp dir: %v", err)
15
+ }
16
+ defer os.RemoveAll(tempDir)
17
+
18
+ settingsPath := filepath.Join(tempDir, ".claude", "settings.json")
19
+
20
+ // Redirect stdout to suppress logs
21
+ oldStdout := os.Stdout
22
+ _, w, _ := os.Pipe()
23
+ os.Stdout = w
24
+ defer func() {
25
+ os.Stdout = oldStdout
26
+ }()
27
+
28
+ t.Run("Create new config", func(t *testing.T) {
29
+ if err := ConfigClaude(settingsPath); err != nil {
30
+ t.Fatalf("ConfigClaude() failed: %v", err)
31
+ }
32
+
33
+ settings := readSettings(t, settingsPath)
34
+ pre, post := checkHooks(settings)
35
+ if !pre {
36
+ t.Error("PreToolUse hook not created")
37
+ }
38
+ if !post {
39
+ t.Error("PostToolUse hook not created")
40
+ }
41
+ })
42
+
43
+ t.Run("Idempotency", func(t *testing.T) {
44
+ // Run it again (first time was in "Create new config")
45
+ if err := ConfigClaude(settingsPath); err != nil {
46
+ t.Fatalf("ConfigClaude() failed on second run: %v", err)
47
+ }
48
+
49
+ settings := readSettings(t, settingsPath)
50
+ pre, post := checkHooks(settings)
51
+ if !pre || !post {
52
+ t.Error("Hooks missing after second run")
53
+ }
54
+
55
+ // Check that we didn't duplicate them (rudimentary check: count of items)
56
+ hooks := settings["hooks"].(map[string]interface{})
57
+ preToolUse := hooks["PreToolUse"].([]interface{})
58
+ postToolUse := hooks["PostToolUse"].([]interface{})
59
+
60
+ // We expect exactly 1 hook in each since we started fresh
61
+ if len(preToolUse) != 1 {
62
+ t.Errorf("Expected 1 PreToolUse hook, got %d", len(preToolUse))
63
+ }
64
+ if len(postToolUse) != 1 {
65
+ t.Errorf("Expected 1 PostToolUse hook, got %d", len(postToolUse))
66
+ }
67
+ })
68
+
69
+ t.Run("Preserve existing hooks", func(t *testing.T) {
70
+ // Reset with a file that has other hooks
71
+ otherHook := map[string]interface{}{
72
+ "hooks": map[string]interface{}{
73
+ "PreToolUse": []interface{}{
74
+ map[string]interface{}{
75
+ "matcher": "Read",
76
+ "hooks": []interface{}{
77
+ map[string]interface{}{"command": "echo existing"},
78
+ },
79
+ },
80
+ },
81
+ },
82
+ }
83
+
84
+ // Clean up previous run
85
+ os.RemoveAll(filepath.Dir(settingsPath))
86
+
87
+ dir := filepath.Dir(settingsPath)
88
+ if err := os.MkdirAll(dir, 0755); err != nil {
89
+ t.Fatalf("Failed to create dir: %v", err)
90
+ }
91
+
92
+ // Write otherHook to settingsPath
93
+ data, _ := json.Marshal(otherHook)
94
+ if err := os.WriteFile(settingsPath, data, 0644); err != nil {
95
+ t.Fatalf("Failed to write settings: %v", err)
96
+ }
97
+
98
+ if err := ConfigClaude(settingsPath); err != nil {
99
+ t.Fatalf("ConfigClaude() failed: %v", err)
100
+ }
101
+
102
+ settings := readSettings(t, settingsPath)
103
+ pre, post := checkHooks(settings)
104
+ if !pre || !post {
105
+ t.Error("Tanagram hooks missing when merging with existing config")
106
+ }
107
+
108
+ // Check if existing hook is still there
109
+ hooks := settings["hooks"].(map[string]interface{})
110
+ preToolUse := hooks["PreToolUse"].([]interface{})
111
+
112
+ foundExisting := false
113
+ for _, h := range preToolUse {
114
+ hMap := h.(map[string]interface{})
115
+ if hMap["matcher"] == "Read" {
116
+ // Check for the specific command to be sure it's the right one
117
+ if commands, ok := hMap["hooks"].([]interface{}); ok {
118
+ for _, cmd := range commands {
119
+ if cmdMap, ok := cmd.(map[string]interface{}); ok {
120
+ if cmdMap["command"] == "echo existing" {
121
+ foundExisting = true
122
+ break
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
128
+ if foundExisting {
129
+ break
130
+ }
131
+ }
132
+ if !foundExisting {
133
+ t.Error("Existing hook was lost")
134
+ }
135
+ })
136
+ }
137
+
138
+ func TestConfigCursor(t *testing.T) {
139
+ // Create a temporary directory
140
+ tempDir, err := os.MkdirTemp("", "tanagram-cursor-test-*")
141
+ if err != nil {
142
+ t.Fatalf("Failed to create temp dir: %v", err)
143
+ }
144
+ defer os.RemoveAll(tempDir)
145
+
146
+ hooksPath := filepath.Join(tempDir, ".cursor", "hooks.json")
147
+
148
+ // Redirect stdout to suppress logs
149
+ oldStdout := os.Stdout
150
+ _, w, _ := os.Pipe()
151
+ os.Stdout = w
152
+ defer func() {
153
+ os.Stdout = oldStdout
154
+ }()
155
+
156
+ t.Run("Create new config", func(t *testing.T) {
157
+ if err := ConfigCursor(hooksPath); err != nil {
158
+ t.Fatalf("ConfigCursor() failed: %v", err)
159
+ }
160
+
161
+ settings := readSettings(t, hooksPath)
162
+ beforeSubmit, stop := checkCursorHooks(settings)
163
+ if !beforeSubmit {
164
+ t.Error("beforeSubmitPrompt hook not created")
165
+ }
166
+ if !stop {
167
+ t.Error("stop hook not created")
168
+ }
169
+ })
170
+
171
+ t.Run("Idempotency", func(t *testing.T) {
172
+ // Run it again
173
+ if err := ConfigCursor(hooksPath); err != nil {
174
+ t.Fatalf("ConfigCursor() failed on second run: %v", err)
175
+ }
176
+
177
+ settings := readSettings(t, hooksPath)
178
+ beforeSubmit, stop := checkCursorHooks(settings)
179
+ if !beforeSubmit || !stop {
180
+ t.Error("Hooks missing after second run")
181
+ }
182
+
183
+ // Check duplicates
184
+ hooks := settings["hooks"].(map[string]interface{})
185
+ beforeSubmitHooks := hooks["beforeSubmitPrompt"].([]interface{})
186
+ stopHooks := hooks["stop"].([]interface{})
187
+
188
+ if len(beforeSubmitHooks) != 1 {
189
+ t.Errorf("Expected 1 beforeSubmitPrompt hook, got %d", len(beforeSubmitHooks))
190
+ }
191
+ if len(stopHooks) != 1 {
192
+ t.Errorf("Expected 1 stop hook, got %d", len(stopHooks))
193
+ }
194
+ })
195
+
196
+ t.Run("Preserve existing hooks", func(t *testing.T) {
197
+ // Reset with a file that has other hooks
198
+ otherHook := map[string]interface{}{
199
+ "hooks": map[string]interface{}{
200
+ "beforeSubmitPrompt": []interface{}{
201
+ map[string]interface{}{"command": "echo existing"},
202
+ },
203
+ },
204
+ }
205
+
206
+ // Clean up previous run
207
+ os.RemoveAll(filepath.Dir(hooksPath))
208
+
209
+ dir := filepath.Dir(hooksPath)
210
+ if err := os.MkdirAll(dir, 0755); err != nil {
211
+ t.Fatalf("Failed to create dir: %v", err)
212
+ }
213
+
214
+ // Write otherHook to hooksPath
215
+ data, _ := json.Marshal(otherHook)
216
+ if err := os.WriteFile(hooksPath, data, 0644); err != nil {
217
+ t.Fatalf("Failed to write settings: %v", err)
218
+ }
219
+
220
+ if err := ConfigCursor(hooksPath); err != nil {
221
+ t.Fatalf("ConfigCursor() failed: %v", err)
222
+ }
223
+
224
+ settings := readSettings(t, hooksPath)
225
+ beforeSubmit, stop := checkCursorHooks(settings)
226
+ if !beforeSubmit || !stop {
227
+ t.Error("Tanagram hooks missing when merging with existing config")
228
+ }
229
+
230
+ // Check if existing hook is still there
231
+ hooks := settings["hooks"].(map[string]interface{})
232
+ beforeSubmitHooks := hooks["beforeSubmitPrompt"].([]interface{})
233
+
234
+ foundExisting := false
235
+ for _, h := range beforeSubmitHooks {
236
+ hMap := h.(map[string]interface{})
237
+ if cmd, ok := hMap["command"].(string); ok && cmd == "echo existing" {
238
+ foundExisting = true
239
+ break
240
+ }
241
+ }
242
+ if !foundExisting {
243
+ t.Error("Existing hook was lost")
244
+ }
245
+ })
246
+ }
247
+
248
+ func TestEnsureHooksConfigured(t *testing.T) {
249
+ // Create a temporary home directory
250
+ tempHome, err := os.MkdirTemp("", "tanagram-home-test-*")
251
+ if err != nil {
252
+ t.Fatalf("Failed to create temp home: %v", err)
253
+ }
254
+ defer os.RemoveAll(tempHome)
255
+
256
+ // Mock HOME environment variable
257
+ originalHome := os.Getenv("HOME")
258
+ defer os.Setenv("HOME", originalHome)
259
+ os.Setenv("HOME", tempHome)
260
+
261
+ // Redirect stdout/stderr to suppress logs
262
+ oldStdout := os.Stdout
263
+ oldStderr := os.Stderr
264
+ _, w, _ := os.Pipe()
265
+ os.Stdout = w
266
+ os.Stderr = w
267
+ defer func() {
268
+ os.Stdout = oldStdout
269
+ os.Stderr = oldStderr
270
+ }()
271
+
272
+ t.Run("Fresh configuration", func(t *testing.T) {
273
+ if err := EnsureHooksConfigured(); err != nil {
274
+ t.Fatalf("EnsureHooksConfigured() failed: %v", err)
275
+ }
276
+
277
+ // Check Claude settings
278
+ claudeSettingsPath := filepath.Join(tempHome, ".claude", "settings.json")
279
+ claudeSettings := readSettings(t, claudeSettingsPath)
280
+ pre, post := checkHooks(claudeSettings)
281
+ if !pre {
282
+ t.Error("PreToolUse hook not created")
283
+ }
284
+ if !post {
285
+ t.Error("PostToolUse hook not created")
286
+ }
287
+
288
+ // Check Cursor hooks
289
+ cursorHooksPath := filepath.Join(tempHome, ".cursor", "hooks.json")
290
+ cursorSettings := readSettings(t, cursorHooksPath)
291
+ beforeSubmit, stop := checkCursorHooks(cursorSettings)
292
+ if !beforeSubmit {
293
+ t.Error("beforeSubmitPrompt hook not created")
294
+ }
295
+ if !stop {
296
+ t.Error("stop hook not created")
297
+ }
298
+ })
299
+ }
300
+
301
+ func readSettings(t *testing.T, settingsPath string) map[string]interface{} {
302
+ data, err := os.ReadFile(settingsPath)
303
+ if err != nil {
304
+ t.Fatalf("Failed to read settings file: %v", err)
305
+ }
306
+ var settings map[string]interface{}
307
+ if err := json.Unmarshal(data, &settings); err != nil {
308
+ t.Fatalf("Failed to parse settings JSON: %v", err)
309
+ }
310
+ return settings
311
+ }
312
+
313
+ func checkHooks(settings map[string]interface{}) (bool, bool) {
314
+ hooks, ok := settings["hooks"].(map[string]interface{})
315
+ if !ok {
316
+ return false, false
317
+ }
318
+
319
+ preExists := false
320
+ if preToolUse, ok := hooks["PreToolUse"].([]interface{}); ok {
321
+ for _, h := range preToolUse {
322
+ if hMap, ok := h.(map[string]interface{}); ok {
323
+ if hMap["matcher"] == "Edit|Write" {
324
+ if innerHooks, ok := hMap["hooks"].([]interface{}); ok {
325
+ for _, ih := range innerHooks {
326
+ if ihMap, ok := ih.(map[string]interface{}); ok {
327
+ if ihMap["command"] == "tanagram snapshot" {
328
+ preExists = true
329
+ }
330
+ }
331
+ }
332
+ }
333
+ }
334
+ }
335
+ }
336
+ }
337
+
338
+ postExists := false
339
+ if postToolUse, ok := hooks["PostToolUse"].([]interface{}); ok {
340
+ for _, h := range postToolUse {
341
+ if hMap, ok := h.(map[string]interface{}); ok {
342
+ if hMap["matcher"] == "Edit|Write" {
343
+ if innerHooks, ok := hMap["hooks"].([]interface{}); ok {
344
+ for _, ih := range innerHooks {
345
+ if ihMap, ok := ih.(map[string]interface{}); ok {
346
+ if ihMap["command"] == "tanagram" {
347
+ postExists = true
348
+ }
349
+ }
350
+ }
351
+ }
352
+ }
353
+ }
354
+ }
355
+ }
356
+
357
+ return preExists, postExists
358
+ }
359
+
360
+ func checkCursorHooks(settings map[string]interface{}) (bool, bool) {
361
+ hooks, ok := settings["hooks"].(map[string]interface{})
362
+ if !ok {
363
+ return false, false
364
+ }
365
+
366
+ beforeSubmitExists := false
367
+ if beforeSubmit, ok := hooks["beforeSubmitPrompt"].([]interface{}); ok {
368
+ for _, h := range beforeSubmit {
369
+ if hMap, ok := h.(map[string]interface{}); ok {
370
+ if cmd, ok := hMap["command"].(string); ok && cmd == "tanagram snapshot" {
371
+ beforeSubmitExists = true
372
+ }
373
+ }
374
+ }
375
+ }
376
+
377
+ stopExists := false
378
+ if stop, ok := hooks["stop"].([]interface{}); ok {
379
+ for _, h := range stop {
380
+ if hMap, ok := h.(map[string]interface{}); ok {
381
+ if cmd, ok := hMap["command"].(string); ok && cmd == "tanagram" {
382
+ stopExists = true
383
+ }
384
+ }
385
+ }
386
+ }
387
+
388
+ return beforeSubmitExists, stopExists
389
+ }
package/commands/run.go CHANGED
@@ -26,10 +26,10 @@ func spinner(stop chan bool, message string) {
26
26
  for {
27
27
  select {
28
28
  case <-stop:
29
- fmt.Print("\r")
29
+ fmt.Fprint(os.Stderr, "\r")
30
30
  return
31
31
  default:
32
- fmt.Printf("\r%s %s", chars[i%len(chars)], message)
32
+ fmt.Fprintf(os.Stderr, "\r%s %s", chars[i%len(chars)], message)
33
33
  i++
34
34
  time.Sleep(100 * time.Millisecond)
35
35
  }
@@ -80,7 +80,7 @@ func Run() error {
80
80
  return err
81
81
  }
82
82
 
83
- fmt.Printf("\nSyncing policies with LLM (processing %d changed file(s) in parallel)...\n", len(filesToSync))
83
+ fmt.Fprintf(os.Stderr, "\nSyncing policies with LLM (processing %d changed file(s) in parallel)...\n", len(filesToSync))
84
84
 
85
85
  syncStart := time.Now()
86
86
  ctx := context.Background()
@@ -107,13 +107,13 @@ func Run() error {
107
107
  for {
108
108
  select {
109
109
  case <-stop:
110
- fmt.Print("\r")
110
+ fmt.Fprint(os.Stderr, "\r")
111
111
  return
112
112
  default:
113
113
  mu.Lock()
114
114
  c := completed
115
115
  mu.Unlock()
116
- fmt.Printf("\r%s Processing files... (%d/%d completed)", chars[i%len(chars)], c, len(filesToSync))
116
+ fmt.Fprintf(os.Stderr, "\r%s Processing files... (%d/%d completed)", chars[i%len(chars)], c, len(filesToSync))
117
117
  i++
118
118
  time.Sleep(100 * time.Millisecond)
119
119
  }
@@ -145,7 +145,7 @@ func Run() error {
145
145
  close(stop)
146
146
  time.Sleep(50 * time.Millisecond)
147
147
  mu.Lock()
148
- fmt.Printf("\r\033[K✗ Failed to process %s\n", result.relPath)
148
+ fmt.Fprintf(os.Stderr, "\r\033[K✗ Failed to process %s\n", result.relPath)
149
149
  mu.Unlock()
150
150
  return fmt.Errorf("failed to extract policies from %s: %w", result.file, result.err)
151
151
  }
@@ -162,7 +162,7 @@ func Run() error {
162
162
  // Atomic update of counter and output (prevents race with spinner)
163
163
  mu.Lock()
164
164
  completed++
165
- fmt.Printf("\r\033[K✓ %s - %d policies\n", result.relPath, len(result.policies))
165
+ fmt.Fprintf(os.Stderr, "\r\033[K✓ %s - %d policies\n", result.relPath, len(result.policies))
166
166
  mu.Unlock()
167
167
  }
168
168
 
@@ -176,7 +176,7 @@ func Run() error {
176
176
  }
177
177
 
178
178
  syncDuration := time.Since(syncStart)
179
- fmt.Printf("\n✓ Synced %d policies from %d changed file(s)\n", totalPolicies, len(filesToSync))
179
+ fmt.Fprintf(os.Stderr, "\n✓ Synced %d policies from %d changed file(s)\n", totalPolicies, len(filesToSync))
180
180
 
181
181
  // Track sync metrics
182
182
  metrics.Track("cli.sync.complete", map[string]interface{}{
@@ -202,10 +202,10 @@ func Run() error {
202
202
  cloudPolicies, err = cloudStorage.LoadCloudPoliciesAsParserFormat(repoInfo.Owner, repoInfo.Name)
203
203
  if err != nil {
204
204
  // Cloud policies exist but failed to load - warn but continue
205
- fmt.Printf("Warning: Failed to load cloud policies: %v\n", err)
205
+ fmt.Fprintf(os.Stderr, "Warning: Failed to load cloud policies: %v\n", err)
206
206
  cloudPolicies = []parser.Policy{}
207
207
  } else if len(cloudPolicies) > 0 {
208
- fmt.Printf("Loaded %d cloud policies for %s/%s\n", len(cloudPolicies), repoInfo.Owner, repoInfo.Name)
208
+ fmt.Fprintf(os.Stderr, "Loaded %d cloud policies for %s/%s\n", len(cloudPolicies), repoInfo.Owner, repoInfo.Name)
209
209
  }
210
210
  }
211
211
  // If repo detection failed, silently continue with local-only policies
@@ -214,7 +214,7 @@ func Run() error {
214
214
  policies := storage.MergePolicies(localPolicies, cloudPolicies)
215
215
 
216
216
  if len(policies) == 0 {
217
- fmt.Println("No enforceable policies found")
217
+ fmt.Fprintln(os.Stderr, "No enforceable policies found")
218
218
  return nil
219
219
  }
220
220
 
@@ -223,9 +223,9 @@ func Run() error {
223
223
  totalMerged := len(policies)
224
224
 
225
225
  if totalCloud > 0 {
226
- fmt.Printf("Total policies: %d (%d local, %d cloud, %d after merge)\n", totalMerged, totalLocal, totalCloud, totalMerged)
226
+ fmt.Fprintf(os.Stderr, "Total policies: %d (%d local, %d cloud, %d after merge)\n", totalMerged, totalLocal, totalCloud, totalMerged)
227
227
  } else {
228
- fmt.Printf("Loaded %d local policies\n", totalLocal)
228
+ fmt.Fprintf(os.Stderr, "Loaded %d local policies\n", totalLocal)
229
229
  }
230
230
 
231
231
  // Check if a snapshot exists (from PreToolUse hook)
@@ -233,7 +233,7 @@ func Run() error {
233
233
  useSnapshot := false
234
234
 
235
235
  if snapshot.Exists(gitRoot) {
236
- fmt.Println("Snapshot detected - checking only Claude's changes...")
236
+ fmt.Fprintln(os.Stderr, "Snapshot detected - checking only Claude's changes...")
237
237
 
238
238
  // Load snapshot
239
239
  snap, err := snapshot.Load(gitRoot)
@@ -271,7 +271,7 @@ func Run() error {
271
271
  useSnapshot = true
272
272
  } else {
273
273
  // No snapshot - fall back to checking all git changes
274
- fmt.Println("Checking all changes (unstaged + staged)...")
274
+ fmt.Fprintln(os.Stderr, "Checking all changes (unstaged + staged)...")
275
275
  diffResult, err := gitpkg.GetAllChanges()
276
276
  if err != nil {
277
277
  return fmt.Errorf("error getting git diff: %w", err)
@@ -282,14 +282,14 @@ func Run() error {
282
282
 
283
283
  if len(changesToCheck) == 0 {
284
284
  if useSnapshot {
285
- fmt.Println("No changes detected since snapshot")
285
+ fmt.Fprintln(os.Stderr, "No changes detected since snapshot")
286
286
  } else {
287
- fmt.Println("No changes to check")
287
+ fmt.Fprintln(os.Stderr, "No changes to check")
288
288
  }
289
289
  return nil
290
290
  }
291
291
 
292
- fmt.Printf("Scanning %d changed lines...\n\n", len(changesToCheck))
292
+ fmt.Fprintf(os.Stderr, "Scanning %d changed lines...\n\n", len(changesToCheck))
293
293
 
294
294
  // Get API key once upfront before checking
295
295
  apiKey, err := config.GetAPIKey()
@@ -325,6 +325,10 @@ func Run() error {
325
325
 
326
326
  // Exit with code 2 to trigger Claude Code hook behavior
327
327
  os.Exit(2)
328
+ } else if parentProcess == "cursor" {
329
+ // Format and output Cursor-friendly instructions to stdout
330
+ cursorInstructions := checker.FormatCursorInstructions(result)
331
+ fmt.Fprint(os.Stdout, cursorInstructions)
328
332
  } else {
329
333
  // Format and output violations to stderr
330
334
  violationOutput := checker.FormatViolations(result)
@@ -333,6 +337,6 @@ func Run() error {
333
337
  }
334
338
 
335
339
  // No violations found - output success message
336
- fmt.Println("✓ No policy violations found")
340
+ fmt.Fprintln(os.Stderr, "✓ No policy violations found")
337
341
  return nil
338
342
  }
@@ -5,21 +5,33 @@ import (
5
5
 
6
6
  "github.com/tanagram/cli/snapshot"
7
7
  "github.com/tanagram/cli/storage"
8
+ "github.com/tanagram/cli/utils"
9
+ )
10
+
11
+ // Extracted variables so that tests can override these
12
+ var (
13
+ findGitRoot = storage.FindGitRoot
14
+ createSnapshot = snapshot.Create
15
+ getParentProcess = utils.GetParentProcess
8
16
  )
9
17
 
10
18
  // Snapshot creates a snapshot of the current working directory state
11
19
  func Snapshot() error {
12
20
  // Find git root
13
- gitRoot, err := storage.FindGitRoot()
21
+ gitRoot, err := findGitRoot()
14
22
  if err != nil {
15
23
  return err
16
24
  }
17
25
 
18
26
  // Create snapshot
19
- if err := snapshot.Create(gitRoot); err != nil {
27
+ if err := createSnapshot(gitRoot); err != nil {
20
28
  return fmt.Errorf("failed to create snapshot: %w", err)
21
29
  }
22
30
 
23
- // Silent success - no output for hooks
31
+ parentProcess := getParentProcess()
32
+ if parentProcess == "Cursor" || parentProcess == "cursor" {
33
+ // https://cursor.com/docs/agent/hooks#beforesubmitprompt
34
+ fmt.Println(`{ "continue": true }`)
35
+ }
24
36
  return nil
25
37
  }
@@ -0,0 +1,88 @@
1
+ package commands
2
+
3
+ import (
4
+ "bytes"
5
+ "io"
6
+ "os"
7
+ "testing"
8
+ )
9
+
10
+ func TestSnapshot_Cursor(t *testing.T) {
11
+ // Save original functions
12
+ origFindGitRoot := findGitRoot
13
+ origCreateSnapshot := createSnapshot
14
+ origGetParentProcess := getParentProcess
15
+ defer func() {
16
+ findGitRoot = origFindGitRoot
17
+ createSnapshot = origCreateSnapshot
18
+ getParentProcess = origGetParentProcess
19
+ }()
20
+
21
+ // Mock functions
22
+ findGitRoot = func() (string, error) {
23
+ return "/tmp/mock-git-root", nil
24
+ }
25
+ createSnapshot = func(root string) error {
26
+ return nil
27
+ }
28
+
29
+ tests := []struct {
30
+ name string
31
+ parentProcess string
32
+ expectedOutput string
33
+ }{
34
+ {
35
+ name: "Cursor process",
36
+ parentProcess: "Cursor",
37
+ expectedOutput: "{ \"continue\": true }\n",
38
+ },
39
+ {
40
+ name: "cursor process lowercase",
41
+ parentProcess: "cursor",
42
+ expectedOutput: "{ \"continue\": true }\n",
43
+ },
44
+ {
45
+ name: "Claude",
46
+ parentProcess: "claude",
47
+ expectedOutput: "",
48
+ },
49
+ {
50
+ name: "Terminal process",
51
+ parentProcess: "zsh",
52
+ expectedOutput: "",
53
+ },
54
+ }
55
+
56
+ for _, tt := range tests {
57
+ t.Run(tt.name, func(t *testing.T) {
58
+ // Set mock
59
+ getParentProcess = func() string {
60
+ return tt.parentProcess
61
+ }
62
+
63
+ // Capture stdout
64
+ oldStdout := os.Stdout
65
+ r, w, _ := os.Pipe()
66
+ os.Stdout = w
67
+
68
+ err := Snapshot()
69
+
70
+ // Restore stdout
71
+ w.Close()
72
+ os.Stdout = oldStdout
73
+
74
+ // Read captured output
75
+ var buf bytes.Buffer
76
+ io.Copy(&buf, r)
77
+ output := buf.String()
78
+
79
+ if err != nil {
80
+ t.Errorf("Snapshot() returned unexpected error: %v", err)
81
+ }
82
+
83
+ if output != tt.expectedOutput {
84
+ t.Errorf("Expected output %q, got %q", tt.expectedOutput, output)
85
+ }
86
+ })
87
+ }
88
+ }