@@ -0,0 +1,530 @@
12+ "github.com/stretchr/testify/require"
15+// createTestLogFile creates a temporary log file with the given entries.
16+func createTestLogFile(t *testing.T, entries []map[string]any) string {
18+ tempDir := t.TempDir()
19+ logFile := filepath.Join(tempDir, "crush.log")
21+ file, err := os.Create(logFile)
22+ require.NoError(t, err)
25+ for _, entry := range entries {
26+ line, err := json.Marshal(entry)
27+ require.NoError(t, err)
28+ _, err = file.WriteString(string(line) + "\n")
29+ require.NoError(t, err)
35+// makeLogEntry creates a standard log entry for testing.
36+func makeLogEntry(level, msg, source string, line int, extra map[string]any) map[string]any {
37+ entry := map[string]any{
38+ "time": time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC).Format(time.RFC3339),
41+ "source": map[string]any{
46+ for k, v := range extra {
52+func TestNewCrushLogsTool(t *testing.T) {
54+ tool := NewCrushLogsTool("/tmp/test.log")
55+ require.NotNil(t, tool)
56+ require.Equal(t, CrushLogsToolName, tool.Info().Name)
59+func TestCrushLogs_HappyPath(t *testing.T) {
61+ entries := []map[string]any{
62+ makeLogEntry("INFO", "Application started", "app.go", 42, map[string]any{"version": "1.0.0"}),
63+ makeLogEntry("DEBUG", "Processing request", "handler.go", 100, map[string]any{"request_id": "abc123"}),
64+ makeLogEntry("ERROR", "Database connection failed", "db.go", 55, map[string]any{"retry_count": 3}),
67+ logFile := createTestLogFile(t, entries)
69+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 3})
71+ lines := strings.Split(result, "\n")
72+ require.Len(t, lines, 3)
74+ // Verify format: TIMESTAMP LEVEL SOURCE:LINE MESSAGE
75+ require.Contains(t, lines[0], "INFO")
76+ require.Contains(t, lines[0], "app.go:42")
77+ require.Contains(t, lines[0], "Application started")
78+ require.Contains(t, lines[0], "version=1.0.0")
80+ require.Contains(t, lines[1], "DEBUG")
81+ require.Contains(t, lines[1], "handler.go:100")
83+ require.Contains(t, lines[2], "ERROR")
84+ require.Contains(t, lines[2], "db.go:55")
87+func TestCrushLogs_DefaultLines(t *testing.T) {
89+ // Create 100 log entries.
90+ var entries []map[string]any
91+ for i := 0; i < 100; i++ {
92+ entries = append(entries, makeLogEntry("INFO", fmt.Sprintf("Entry %d", i), "app.go", i, nil))
95+ logFile := createTestLogFile(t, entries)
97+ // Call with Lines: 0 should default to 50.
98+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 0})
100+ lines := strings.Split(result, "\n")
101+ require.Len(t, lines, 50)
103+ // Verify we got the last 50 entries (entry 50-99).
104+ require.Contains(t, lines[0], "Entry 50")
105+ require.Contains(t, lines[49], "Entry 99")
108+func TestCrushLogs_MaxCap(t *testing.T) {
110+ // Create 200 log entries.
111+ var entries []map[string]any
112+ for i := 0; i < 200; i++ {
113+ entries = append(entries, makeLogEntry("INFO", fmt.Sprintf("Entry %d", i), "app.go", i, nil))
116+ logFile := createTestLogFile(t, entries)
118+ // Request 200 lines, but should only get 100 (max cap).
119+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 200})
121+ lines := strings.Split(result, "\n")
122+ require.Len(t, lines, 100)
125+func TestCrushLogs_MissingFile(t *testing.T) {
127+ result := runCrushLogs("/nonexistent/path/crush.log", CrushLogsParams{Lines: 50})
128+ require.Contains(t, result, "No log file found")
131+func TestCrushLogs_EmptyFile(t *testing.T) {
133+ tempDir := t.TempDir()
134+ logFile := filepath.Join(tempDir, "crush.log")
135+ _, err := os.Create(logFile)
136+ require.NoError(t, err)
138+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 50})
139+ require.Contains(t, result, "Log file is empty")
142+func TestCrushLogs_MalformedLines(t *testing.T) {
144+ tempDir := t.TempDir()
145+ logFile := filepath.Join(tempDir, "crush.log")
147+ file, err := os.Create(logFile)
148+ require.NoError(t, err)
150+ // Write some valid and some invalid lines.
151+ validEntry := makeLogEntry("INFO", "Valid entry", "app.go", 1, nil)
152+ line, _ := json.Marshal(validEntry)
153+ file.WriteString(string(line) + "\n")
154+ file.WriteString("this is not json\n")
155+ file.WriteString(`{"incomplete": "json` + "\n")
157+ validEntry2 := makeLogEntry("INFO", "Another valid entry", "app.go", 2, nil)
158+ line2, _ := json.Marshal(validEntry2)
159+ file.WriteString(string(line2) + "\n")
163+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 10})
165+ lines := strings.Split(result, "\n")
166+ // Only 2 valid lines should be returned.
167+ require.Len(t, lines, 2)
168+ require.Contains(t, lines[0], "Valid entry")
169+ require.Contains(t, lines[1], "Another valid entry")
172+func TestCrushLogs_ExtraFieldsSorted(t *testing.T) {
174+ entries := []map[string]any{
175+ makeLogEntry("INFO", "Test message", "app.go", 1, map[string]any{
178+ "m_field": "middle",
182+ logFile := createTestLogFile(t, entries)
184+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
186+ // Fields should be sorted alphabetically.
187+ idxA := strings.Index(result, "a_field=first")
188+ idxM := strings.Index(result, "m_field=middle")
189+ idxZ := strings.Index(result, "z_field=last")
191+ require.True(t, idxA < idxM, "a_field should come before m_field")
192+ require.True(t, idxM < idxZ, "m_field should come before z_field")
195+func TestCrushLogs_NonStringValues(t *testing.T) {
197+ entry := map[string]any{
198+ "time": time.Now().Format(time.RFC3339),
200+ "msg": "Test values",
201+ "source": map[string]any{"file": "app.go", "line": 1},
206+ "obj": map[string]any{"key": "value"},
207+ "arr": []any{1, 2, 3},
210+ logFile := createTestLogFile(t, []map[string]any{entry})
212+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
214+ // Numbers should be bare (not quoted).
215+ require.Contains(t, result, "count=42")
216+ require.Contains(t, result, "ratio=3.14")
218+ // Booleans should be bare.
219+ require.Contains(t, result, "active=true")
221+ // Null should be bare.
222+ require.Contains(t, result, "data=null")
224+ // Objects and arrays should be JSON-encoded and quoted.
225+ require.Contains(t, result, `obj="{`)
226+ require.Contains(t, result, `arr="[`)
229+func TestCrushLogs_Redaction(t *testing.T) {
231+ entries := []map[string]any{
232+ makeLogEntry("INFO", "API call", "api.go", 10, map[string]any{
233+ "authorization": "Bearer secret123",
234+ "api_key": "my-api-key",
235+ "api-key": "my-api-key-2",
236+ "apikey": "myapikey",
238+ "secret": "mysecret",
239+ "password": "mypassword",
240+ "credential": "mycred",
241+ "Authorization": "Bearer secret456",
242+ "API_KEY": "uppercase",
243+ "my_token_value": "nestedtoken",
247+ logFile := createTestLogFile(t, entries)
249+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
251+ // All sensitive fields should be redacted.
252+ require.Contains(t, result, "authorization=[REDACTED]")
253+ require.Contains(t, result, "api_key=[REDACTED]")
254+ require.Contains(t, result, "api-key=[REDACTED]")
255+ require.Contains(t, result, "apikey=[REDACTED]")
256+ require.Contains(t, result, "token=[REDACTED]")
257+ require.Contains(t, result, "secret=[REDACTED]")
258+ require.Contains(t, result, "password=[REDACTED]")
259+ require.Contains(t, result, "credential=[REDACTED]")
260+ require.Contains(t, result, "Authorization=[REDACTED]")
261+ require.Contains(t, result, "API_KEY=[REDACTED]")
262+ require.Contains(t, result, "my_token_value=[REDACTED]")
264+ // Original sensitive values should not appear.
265+ require.NotContains(t, result, "secret123")
266+ require.NotContains(t, result, "my-api-key")
267+ require.NotContains(t, result, "mytoken")
270+func TestCrushLogs_ReservedFields(t *testing.T) {
272+ entries := []map[string]any{
274+ "time": time.Now().Format(time.RFC3339),
277+ "source": map[string]any{"file": "app.go", "line": 1},
278+ "Time": "should be reserved",
279+ "LEVEL": "should be reserved",
280+ "Msg": "should be reserved",
281+ "SOURCE": "should be reserved",
282+ "extra": "should appear",
286+ logFile := createTestLogFile(t, entries)
288+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
290+ // Reserved fields (case-insensitive) should not appear in extra fields.
291+ require.NotContains(t, result, "Time=")
292+ require.NotContains(t, result, "LEVEL=")
293+ require.NotContains(t, result, "Msg=")
294+ require.NotContains(t, result, "SOURCE=")
295+ require.NotContains(t, result, "time=") // The extra time field
296+ require.NotContains(t, result, "level=") // The extra level field
298+ // Non-reserved field should appear (quoted since it has spaces).
299+ require.Contains(t, result, `extra="should appear"`)
302+func TestCrushLogs_OversizedLines(t *testing.T) {
304+ tempDir := t.TempDir()
305+ logFile := filepath.Join(tempDir, "crush.log")
307+ file, err := os.Create(logFile)
308+ require.NoError(t, err)
310+ // Create a valid entry first.
311+ validEntry := makeLogEntry("INFO", "Valid entry", "app.go", 1, nil)
312+ line, _ := json.Marshal(validEntry)
313+ file.WriteString(string(line) + "\n")
315+ // Create an oversized line (more than 1 MB).
316+ bigValue := strings.Repeat("x", maxLogLineSize+1000)
317+ bigEntry := map[string]any{
318+ "time": time.Now().Format(time.RFC3339),
320+ "msg": "Big message",
321+ "source": map[string]any{"file": "big.go", "line": 1},
324+ bigLine, _ := json.Marshal(bigEntry)
325+ file.WriteString(string(bigLine) + "\n")
327+ // Create another valid entry.
328+ validEntry2 := makeLogEntry("INFO", "Second valid entry", "app.go", 2, nil)
329+ line2, _ := json.Marshal(validEntry2)
330+ file.WriteString(string(line2) + "\n")
334+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 10})
336+ lines := strings.Split(result, "\n")
338+ // Only the 2 valid entries should be returned (oversized one skipped).
339+ require.Len(t, lines, 2)
340+ require.Contains(t, lines[0], "Valid entry")
341+ require.Contains(t, lines[1], "Second valid entry")
344+func TestCrushLogs_PartialTrailingLine(t *testing.T) {
346+ tempDir := t.TempDir()
347+ logFile := filepath.Join(tempDir, "crush.log")
349+ file, err := os.Create(logFile)
350+ require.NoError(t, err)
352+ // Create valid entries.
353+ for i := 0; i < 5; i++ {
354+ entry := makeLogEntry("INFO", fmt.Sprintf("Entry %d", i), "app.go", i, nil)
355+ line, _ := json.Marshal(entry)
356+ file.WriteString(string(line) + "\n")
359+ // Write a partial/truncated line (no closing brace or newline).
360+ file.WriteString(`{"time": "2024-01-15T10:00:00Z", "level": "INFO", "msg": "Truncated`)
363+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 10})
365+ lines := strings.Split(result, "\n")
367+ // Should get the 5 valid entries, truncated line is skipped.
368+ require.Len(t, lines, 5)
369+ for i, line := range lines {
370+ require.Contains(t, line, fmt.Sprintf("Entry %d", i))
374+func TestCrushLogs_ValueQuoting(t *testing.T) {
376+ entries := []map[string]any{
377+ makeLogEntry("INFO", "Test", "app.go", 1, map[string]any{
379+ "with_spaces": "hello world",
380+ "with_equals": "a=b",
381+ "with_newline": "line1\nline2",
382+ "with_quote": `say "hello"`,
383+ "with_backslash": "path\\to\\file",
384+ "normal": "simplevalue",
388+ logFile := createTestLogFile(t, entries)
390+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
392+ // Empty strings should be quoted.
393+ require.Contains(t, result, `empty=""`)
395+ // Strings with spaces should be quoted.
396+ require.Contains(t, result, `with_spaces="hello world"`)
398+ // Strings with = should be quoted.
399+ require.Contains(t, result, `with_equals="a=b"`)
401+ // Strings with newlines should escape them.
402+ require.Contains(t, result, `with_newline="line1\nline2"`)
404+ // Strings with quotes should escape them.
405+ require.Contains(t, result, `with_quote="say \"hello\""`)
407+ // Strings with backslashes should escape them.
408+ require.Contains(t, result, `with_backslash="path\\to\\file"`)
410+ // Normal strings without special chars should be bare.
411+ require.Contains(t, result, "normal=simplevalue")
414+func TestCrushLogs_ChronologicalOrder(t *testing.T) {
416+ // Create entries with different timestamps.
417+ baseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
418+ entries := []map[string]any{
420+ "time": baseTime.Add(0 * time.Second).Format(time.RFC3339),
423+ "source": map[string]any{"file": "app.go", "line": 1},
426+ "time": baseTime.Add(1 * time.Second).Format(time.RFC3339),
429+ "source": map[string]any{"file": "app.go", "line": 2},
432+ "time": baseTime.Add(2 * time.Second).Format(time.RFC3339),
435+ "source": map[string]any{"file": "app.go", "line": 3},
439+ logFile := createTestLogFile(t, entries)
441+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 3})
443+ lines := strings.Split(result, "\n")
445+ // Verify chronological order (oldest first).
446+ require.Len(t, lines, 3)
447+ require.Contains(t, lines[0], "First")
448+ require.Contains(t, lines[1], "Second")
449+ require.Contains(t, lines[2], "Third")
452+func TestCrushLogs_TimeOnlyFormat(t *testing.T) {
454+ entry := map[string]any{
455+ "time": "2024-01-15T15:04:05Z",
458+ "source": map[string]any{"file": "app.go", "line": 1},
461+ logFile := createTestLogFile(t, []map[string]any{entry})
463+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
465+ // Should show time-only format.
466+ require.True(t, strings.HasPrefix(result, "15:04:05"), "Expected time-only format, got: %s", result)
469+func TestCrushLogs_LevelVariations(t *testing.T) {
471+ entries := []map[string]any{
472+ makeLogEntry("DEBUG", "Debug message", "app.go", 1, nil),
473+ makeLogEntry("INFO", "Info message", "app.go", 2, nil),
474+ makeLogEntry("WARN", "Warn message", "app.go", 3, nil),
475+ makeLogEntry("WARNING", "Warning message", "app.go", 4, nil),
476+ makeLogEntry("ERROR", "Error message", "app.go", 5, nil),
479+ logFile := createTestLogFile(t, entries)
481+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 5})
483+ lines := strings.Split(result, "\n")
484+ require.Len(t, lines, 5)
486+ // Check level normalization.
487+ require.Contains(t, lines[0], "DEBUG")
488+ require.Contains(t, lines[1], "INFO")
489+ require.Contains(t, lines[2], "WARN")
490+ require.Contains(t, lines[3], "WARN") // WARNING -> WARN
491+ require.Contains(t, lines[4], "ERROR")
494+func TestCrushLogs_SourceVariations(t *testing.T) {
496+ entries := []map[string]any{
497+ // Source as object with file and line.
499+ "time": time.Now().Format(time.RFC3339),
501+ "msg": "Object source",
502+ "source": map[string]any{"file": "/path/to/app.go", "line": 42},
504+ // Source as string.
506+ "time": time.Now().Format(time.RFC3339),
508+ "msg": "String source",
509+ "source": "/path/to/handler.go:100",
513+ "time": time.Now().Format(time.RFC3339),
519+ logFile := createTestLogFile(t, entries)
521+ result := runCrushLogs(logFile, CrushLogsParams{Lines: 3})
523+ lines := strings.Split(result, "\n")
524+ require.Len(t, lines, 3)
526+ // Check source formatting (should use basename only).
527+ require.Contains(t, lines[0], "app.go:42")
528+ require.Contains(t, lines[1], "handler.go") // String source gets basename too
529+ require.Contains(t, lines[2], "unknown:0") // Missing source