1package tools
2
3import (
4 "encoding/json"
5 "fmt"
6 "maps"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/require"
14)
15
16// createTestLogFile creates a temporary log file with the given entries.
17func createTestLogFile(t *testing.T, entries []map[string]any) string {
18 t.Helper()
19 tempDir := t.TempDir()
20 logFile := filepath.Join(tempDir, "crush.log")
21
22 file, err := os.Create(logFile)
23 require.NoError(t, err)
24 defer file.Close()
25
26 for _, entry := range entries {
27 line, err := json.Marshal(entry)
28 require.NoError(t, err)
29 _, err = file.WriteString(string(line) + "\n")
30 require.NoError(t, err)
31 }
32
33 return logFile
34}
35
36// makeLogEntry creates a standard log entry for testing.
37func makeLogEntry(level, msg, source string, line int, extra map[string]any) map[string]any {
38 entry := map[string]any{
39 "time": time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC).Format(time.RFC3339),
40 "level": level,
41 "msg": msg,
42 "source": map[string]any{
43 "file": source,
44 "line": line,
45 },
46 }
47 maps.Copy(entry, extra)
48 return entry
49}
50
51func TestNewCrushLogsTool(t *testing.T) {
52 t.Parallel()
53 tool := NewCrushLogsTool("/tmp/test.log")
54 require.NotNil(t, tool)
55 require.Equal(t, CrushLogsToolName, tool.Info().Name)
56}
57
58func TestCrushLogs_HappyPath(t *testing.T) {
59 t.Parallel()
60 entries := []map[string]any{
61 makeLogEntry("INFO", "Application started", "app.go", 42, map[string]any{"version": "1.0.0"}),
62 makeLogEntry("DEBUG", "Processing request", "handler.go", 100, map[string]any{"request_id": "abc123"}),
63 makeLogEntry("ERROR", "Database connection failed", "db.go", 55, map[string]any{"retry_count": 3}),
64 }
65
66 logFile := createTestLogFile(t, entries)
67
68 result := runCrushLogs(logFile, CrushLogsParams{Lines: 3})
69
70 lines := strings.Split(result, "\n")
71 require.Len(t, lines, 3)
72
73 // Verify format: TIMESTAMP LEVEL SOURCE:LINE MESSAGE
74 require.Contains(t, lines[0], "INFO")
75 require.Contains(t, lines[0], "app.go:42")
76 require.Contains(t, lines[0], "Application started")
77 require.Contains(t, lines[0], "version=1.0.0")
78
79 require.Contains(t, lines[1], "DEBUG")
80 require.Contains(t, lines[1], "handler.go:100")
81
82 require.Contains(t, lines[2], "ERROR")
83 require.Contains(t, lines[2], "db.go:55")
84}
85
86func TestCrushLogs_DefaultLines(t *testing.T) {
87 t.Parallel()
88 // Create 100 log entries.
89 var entries []map[string]any
90 for i := range 100 {
91 entries = append(entries, makeLogEntry("INFO", fmt.Sprintf("Entry %d", i), "app.go", i, nil))
92 }
93
94 logFile := createTestLogFile(t, entries)
95
96 // Call with Lines: 0 should default to 50.
97 result := runCrushLogs(logFile, CrushLogsParams{Lines: 0})
98
99 lines := strings.Split(result, "\n")
100 require.Len(t, lines, 50)
101
102 // Verify we got the last 50 entries (entry 50-99).
103 require.Contains(t, lines[0], "Entry 50")
104 require.Contains(t, lines[49], "Entry 99")
105}
106
107func TestCrushLogs_MaxCap(t *testing.T) {
108 t.Parallel()
109 // Create 200 log entries.
110 var entries []map[string]any
111 for i := range 200 {
112 entries = append(entries, makeLogEntry("INFO", fmt.Sprintf("Entry %d", i), "app.go", i, nil))
113 }
114
115 logFile := createTestLogFile(t, entries)
116
117 // Request 200 lines, but should only get 100 (max cap).
118 result := runCrushLogs(logFile, CrushLogsParams{Lines: 200})
119
120 lines := strings.Split(result, "\n")
121 require.Len(t, lines, 100)
122}
123
124func TestCrushLogs_MissingFile(t *testing.T) {
125 t.Parallel()
126 result := runCrushLogs("/nonexistent/path/crush.log", CrushLogsParams{Lines: 50})
127 require.Contains(t, result, "No log file found")
128}
129
130func TestCrushLogs_EmptyFile(t *testing.T) {
131 t.Parallel()
132 tempDir := t.TempDir()
133 logFile := filepath.Join(tempDir, "crush.log")
134 _, err := os.Create(logFile)
135 require.NoError(t, err)
136
137 result := runCrushLogs(logFile, CrushLogsParams{Lines: 50})
138 require.Contains(t, result, "Log file is empty")
139}
140
141func TestCrushLogs_MalformedLines(t *testing.T) {
142 t.Parallel()
143 tempDir := t.TempDir()
144 logFile := filepath.Join(tempDir, "crush.log")
145
146 file, err := os.Create(logFile)
147 require.NoError(t, err)
148
149 // Write some valid and some invalid lines.
150 validEntry := makeLogEntry("INFO", "Valid entry", "app.go", 1, nil)
151 line, _ := json.Marshal(validEntry)
152 file.WriteString(string(line) + "\n")
153 file.WriteString("this is not json\n")
154 file.WriteString(`{"incomplete": "json` + "\n")
155
156 validEntry2 := makeLogEntry("INFO", "Another valid entry", "app.go", 2, nil)
157 line2, _ := json.Marshal(validEntry2)
158 file.WriteString(string(line2) + "\n")
159
160 file.Close()
161
162 result := runCrushLogs(logFile, CrushLogsParams{Lines: 10})
163
164 lines := strings.Split(result, "\n")
165 // Only 2 valid lines should be returned.
166 require.Len(t, lines, 2)
167 require.Contains(t, lines[0], "Valid entry")
168 require.Contains(t, lines[1], "Another valid entry")
169}
170
171func TestCrushLogs_ExtraFieldsSorted(t *testing.T) {
172 t.Parallel()
173 entries := []map[string]any{
174 makeLogEntry("INFO", "Test message", "app.go", 1, map[string]any{
175 "z_field": "last",
176 "a_field": "first",
177 "m_field": "middle",
178 }),
179 }
180
181 logFile := createTestLogFile(t, entries)
182
183 result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
184
185 // Fields should be sorted alphabetically.
186 idxA := strings.Index(result, "a_field=first")
187 idxM := strings.Index(result, "m_field=middle")
188 idxZ := strings.Index(result, "z_field=last")
189
190 require.True(t, idxA < idxM, "a_field should come before m_field")
191 require.True(t, idxM < idxZ, "m_field should come before z_field")
192}
193
194func TestCrushLogs_NonStringValues(t *testing.T) {
195 t.Parallel()
196 entry := map[string]any{
197 "time": time.Now().Format(time.RFC3339),
198 "level": "INFO",
199 "msg": "Test values",
200 "source": map[string]any{"file": "app.go", "line": 1},
201 "count": 42,
202 "ratio": 3.14,
203 "active": true,
204 "data": nil,
205 "obj": map[string]any{"key": "value"},
206 "arr": []any{1, 2, 3},
207 }
208
209 logFile := createTestLogFile(t, []map[string]any{entry})
210
211 result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
212
213 // Numbers should be bare (not quoted).
214 require.Contains(t, result, "count=42")
215 require.Contains(t, result, "ratio=3.14")
216
217 // Booleans should be bare.
218 require.Contains(t, result, "active=true")
219
220 // Null should be bare.
221 require.Contains(t, result, "data=null")
222
223 // Objects and arrays should be JSON-encoded and quoted.
224 require.Contains(t, result, `obj="{`)
225 require.Contains(t, result, `arr="[`)
226}
227
228func TestCrushLogs_Redaction(t *testing.T) {
229 t.Parallel()
230 entries := []map[string]any{
231 makeLogEntry("INFO", "API call", "api.go", 10, map[string]any{
232 "authorization": "Bearer secret123",
233 "api_key": "my-api-key",
234 "api-key": "my-api-key-2",
235 "apikey": "myapikey",
236 "token": "mytoken",
237 "secret": "mysecret",
238 "password": "mypassword",
239 "credential": "mycred",
240 "Authorization": "Bearer secret456",
241 "API_KEY": "uppercase",
242 "my_token_value": "nestedtoken",
243 }),
244 }
245
246 logFile := createTestLogFile(t, entries)
247
248 result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
249
250 // All sensitive fields should be redacted.
251 require.Contains(t, result, "authorization=[REDACTED]")
252 require.Contains(t, result, "api_key=[REDACTED]")
253 require.Contains(t, result, "api-key=[REDACTED]")
254 require.Contains(t, result, "apikey=[REDACTED]")
255 require.Contains(t, result, "token=[REDACTED]")
256 require.Contains(t, result, "secret=[REDACTED]")
257 require.Contains(t, result, "password=[REDACTED]")
258 require.Contains(t, result, "credential=[REDACTED]")
259 require.Contains(t, result, "Authorization=[REDACTED]")
260 require.Contains(t, result, "API_KEY=[REDACTED]")
261 require.Contains(t, result, "my_token_value=[REDACTED]")
262
263 // Original sensitive values should not appear.
264 require.NotContains(t, result, "secret123")
265 require.NotContains(t, result, "my-api-key")
266 require.NotContains(t, result, "mytoken")
267}
268
269func TestCrushLogs_ReservedFields(t *testing.T) {
270 t.Parallel()
271 entries := []map[string]any{
272 {
273 "time": time.Now().Format(time.RFC3339),
274 "level": "INFO",
275 "msg": "Test",
276 "source": map[string]any{"file": "app.go", "line": 1},
277 "Time": "should be reserved",
278 "LEVEL": "should be reserved",
279 "Msg": "should be reserved",
280 "SOURCE": "should be reserved",
281 "extra": "should appear",
282 },
283 }
284
285 logFile := createTestLogFile(t, entries)
286
287 result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
288
289 // Reserved fields (case-insensitive) should not appear in extra fields.
290 require.NotContains(t, result, "Time=")
291 require.NotContains(t, result, "LEVEL=")
292 require.NotContains(t, result, "Msg=")
293 require.NotContains(t, result, "SOURCE=")
294 require.NotContains(t, result, "time=") // The extra time field
295 require.NotContains(t, result, "level=") // The extra level field
296
297 // Non-reserved field should appear (quoted since it has spaces).
298 require.Contains(t, result, `extra="should appear"`)
299}
300
301func TestCrushLogs_OversizedLines(t *testing.T) {
302 t.Parallel()
303 tempDir := t.TempDir()
304 logFile := filepath.Join(tempDir, "crush.log")
305
306 file, err := os.Create(logFile)
307 require.NoError(t, err)
308
309 // Create a valid entry first.
310 validEntry := makeLogEntry("INFO", "Valid entry", "app.go", 1, nil)
311 line, _ := json.Marshal(validEntry)
312 file.WriteString(string(line) + "\n")
313
314 // Create an oversized line (more than 1 MB).
315 bigValue := strings.Repeat("x", maxLogLineSize+1000)
316 bigEntry := map[string]any{
317 "time": time.Now().Format(time.RFC3339),
318 "level": "INFO",
319 "msg": "Big message",
320 "source": map[string]any{"file": "big.go", "line": 1},
321 "data": bigValue,
322 }
323 bigLine, _ := json.Marshal(bigEntry)
324 file.WriteString(string(bigLine) + "\n")
325
326 // Create another valid entry.
327 validEntry2 := makeLogEntry("INFO", "Second valid entry", "app.go", 2, nil)
328 line2, _ := json.Marshal(validEntry2)
329 file.WriteString(string(line2) + "\n")
330
331 file.Close()
332
333 result := runCrushLogs(logFile, CrushLogsParams{Lines: 10})
334
335 lines := strings.Split(result, "\n")
336
337 // Only the 2 valid entries should be returned (oversized one skipped).
338 require.Len(t, lines, 2)
339 require.Contains(t, lines[0], "Valid entry")
340 require.Contains(t, lines[1], "Second valid entry")
341}
342
343func TestCrushLogs_PartialTrailingLine(t *testing.T) {
344 t.Parallel()
345 tempDir := t.TempDir()
346 logFile := filepath.Join(tempDir, "crush.log")
347
348 file, err := os.Create(logFile)
349 require.NoError(t, err)
350
351 // Create valid entries.
352 for i := range 5 {
353 entry := makeLogEntry("INFO", fmt.Sprintf("Entry %d", i), "app.go", i, nil)
354 line, _ := json.Marshal(entry)
355 file.WriteString(string(line) + "\n")
356 }
357
358 // Write a partial/truncated line (no closing brace or newline).
359 file.WriteString(`{"time": "2024-01-15T10:00:00Z", "level": "INFO", "msg": "Truncated`)
360 file.Close()
361
362 result := runCrushLogs(logFile, CrushLogsParams{Lines: 10})
363
364 lines := strings.Split(result, "\n")
365
366 // Should get the 5 valid entries, truncated line is skipped.
367 require.Len(t, lines, 5)
368 for i, line := range lines {
369 require.Contains(t, line, fmt.Sprintf("Entry %d", i))
370 }
371}
372
373func TestCrushLogs_ValueQuoting(t *testing.T) {
374 t.Parallel()
375 entries := []map[string]any{
376 makeLogEntry("INFO", "Test", "app.go", 1, map[string]any{
377 "empty": "",
378 "with_spaces": "hello world",
379 "with_equals": "a=b",
380 "with_newline": "line1\nline2",
381 "with_quote": `say "hello"`,
382 "with_backslash": "path\\to\\file",
383 "normal": "simplevalue",
384 }),
385 }
386
387 logFile := createTestLogFile(t, entries)
388
389 result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
390
391 // Empty strings should be quoted.
392 require.Contains(t, result, `empty=""`)
393
394 // Strings with spaces should be quoted.
395 require.Contains(t, result, `with_spaces="hello world"`)
396
397 // Strings with = should be quoted.
398 require.Contains(t, result, `with_equals="a=b"`)
399
400 // Strings with newlines should escape them.
401 require.Contains(t, result, `with_newline="line1\nline2"`)
402
403 // Strings with quotes should escape them.
404 require.Contains(t, result, `with_quote="say \"hello\""`)
405
406 // Strings with backslashes should escape them.
407 require.Contains(t, result, `with_backslash="path\\to\\file"`)
408
409 // Normal strings without special chars should be bare.
410 require.Contains(t, result, "normal=simplevalue")
411}
412
413func TestCrushLogs_ChronologicalOrder(t *testing.T) {
414 t.Parallel()
415 // Create entries with different timestamps.
416 baseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
417 entries := []map[string]any{
418 {
419 "time": baseTime.Add(0 * time.Second).Format(time.RFC3339),
420 "level": "INFO",
421 "msg": "First",
422 "source": map[string]any{"file": "app.go", "line": 1},
423 },
424 {
425 "time": baseTime.Add(1 * time.Second).Format(time.RFC3339),
426 "level": "INFO",
427 "msg": "Second",
428 "source": map[string]any{"file": "app.go", "line": 2},
429 },
430 {
431 "time": baseTime.Add(2 * time.Second).Format(time.RFC3339),
432 "level": "INFO",
433 "msg": "Third",
434 "source": map[string]any{"file": "app.go", "line": 3},
435 },
436 }
437
438 logFile := createTestLogFile(t, entries)
439
440 result := runCrushLogs(logFile, CrushLogsParams{Lines: 3})
441
442 lines := strings.Split(result, "\n")
443
444 // Verify chronological order (oldest first).
445 require.Len(t, lines, 3)
446 require.Contains(t, lines[0], "First")
447 require.Contains(t, lines[1], "Second")
448 require.Contains(t, lines[2], "Third")
449}
450
451func TestCrushLogs_TimeOnlyFormat(t *testing.T) {
452 t.Parallel()
453 entry := map[string]any{
454 "time": "2024-01-15T15:04:05Z",
455 "level": "INFO",
456 "msg": "Test",
457 "source": map[string]any{"file": "app.go", "line": 1},
458 }
459
460 logFile := createTestLogFile(t, []map[string]any{entry})
461
462 result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
463
464 // Should show time-only format.
465 require.True(t, strings.HasPrefix(result, "15:04:05"), "Expected time-only format, got: %s", result)
466}
467
468func TestCrushLogs_LevelVariations(t *testing.T) {
469 t.Parallel()
470 entries := []map[string]any{
471 makeLogEntry("DEBUG", "Debug message", "app.go", 1, nil),
472 makeLogEntry("INFO", "Info message", "app.go", 2, nil),
473 makeLogEntry("WARN", "Warn message", "app.go", 3, nil),
474 makeLogEntry("WARNING", "Warning message", "app.go", 4, nil),
475 makeLogEntry("ERROR", "Error message", "app.go", 5, nil),
476 }
477
478 logFile := createTestLogFile(t, entries)
479
480 result := runCrushLogs(logFile, CrushLogsParams{Lines: 5})
481
482 lines := strings.Split(result, "\n")
483 require.Len(t, lines, 5)
484
485 // Check level normalization.
486 require.Contains(t, lines[0], "DEBUG")
487 require.Contains(t, lines[1], "INFO")
488 require.Contains(t, lines[2], "WARN")
489 require.Contains(t, lines[3], "WARN") // WARNING -> WARN
490 require.Contains(t, lines[4], "ERROR")
491}
492
493func TestCrushLogs_SourceVariations(t *testing.T) {
494 t.Parallel()
495 entries := []map[string]any{
496 // Source as object with file and line.
497 {
498 "time": time.Now().Format(time.RFC3339),
499 "level": "INFO",
500 "msg": "Object source",
501 "source": map[string]any{"file": "/path/to/app.go", "line": 42},
502 },
503 // Source as string.
504 {
505 "time": time.Now().Format(time.RFC3339),
506 "level": "INFO",
507 "msg": "String source",
508 "source": "/path/to/handler.go:100",
509 },
510 // Missing source.
511 {
512 "time": time.Now().Format(time.RFC3339),
513 "level": "INFO",
514 "msg": "No source",
515 },
516 }
517
518 logFile := createTestLogFile(t, entries)
519
520 result := runCrushLogs(logFile, CrushLogsParams{Lines: 3})
521
522 lines := strings.Split(result, "\n")
523 require.Len(t, lines, 3)
524
525 // Check source formatting (should use basename only).
526 require.Contains(t, lines[0], "app.go:42")
527 require.Contains(t, lines[1], "handler.go") // String source gets basename too
528 require.Contains(t, lines[2], "unknown:0") // Missing source
529}