diff --git a/cli/logs.go b/cli/logs.go index b241761..adaa132 100644 --- a/cli/logs.go +++ b/cli/logs.go @@ -14,14 +14,15 @@ var deviceLogsCmd = &cobra.Command{ Long: `Streams real-time logs from a device. Press Ctrl+C to stop. Filters use key=value (include) or key!=value (exclude) syntax. -Multiple --filter flags are ANDed together. +Multiple --filter flags are ANDed together. Matching is case-insensitive; +'message' matches as a substring, every other key matches the whole value. Supported keys: pid, process, tag, level, subsystem, category, message Examples: mobilecli device logs --filter tag=ActivityManager mobilecli device logs --filter process!=SpringBoard - mobilecli device logs --filter level=Error --filter process=backboardd`, + mobilecli device logs --filter level=error --filter message=timeout`, RunE: func(cmd *cobra.Command, args []string) error { if _, err := commands.ParseLogFilters(logsFilters); err != nil { return err diff --git a/commands/logs.go b/commands/logs.go index e6297e7..5aa2ed5 100644 --- a/commands/logs.go +++ b/commands/logs.go @@ -86,10 +86,22 @@ func getFieldValue(entry devices.LogEntry, key string) string { } } +// fieldMatches compares a log field against a filter value. Matching is +// case-insensitive everywhere so "level=error" matches "Error" (iOS and Android +// capitalize levels differently), and "message" matches as a substring so a free +// text filter like "message=timeout" finds the entries that contain it rather +// than demanding the whole line. +func fieldMatches(key, fieldValue, filterValue string) bool { + if key == "message" { + return strings.Contains(strings.ToLower(fieldValue), strings.ToLower(filterValue)) + } + return strings.EqualFold(fieldValue, filterValue) +} + func matchesFilters(entry devices.LogEntry, filters []LogFilter) bool { for _, f := range filters { fieldValue := getFieldValue(entry, f.Key) - match := fieldValue == f.Value + match := fieldMatches(f.Key, fieldValue, f.Value) if f.Negate { match = !match } diff --git a/commands/logs_test.go b/commands/logs_test.go index 166959e..396e02d 100644 --- a/commands/logs_test.go +++ b/commands/logs_test.go @@ -69,6 +69,11 @@ func TestMatchesFiltersRequiresEveryFilterToPass(t *testing.T) { {"one filter of many fails", []string{"level=Error", "tag=Zygote"}, false}, {"numeric pid is compared as text", []string{"pid=42"}, true}, {"empty field never matches a value", []string{"subsystem=com.apple.UIKit"}, false}, + {"level is case-insensitive", []string{"level=error"}, true}, + {"level upper-case still matches", []string{"level=ERROR"}, true}, + {"case-insensitive exclude fails", []string{"level!=error"}, false}, + {"process is case-insensitive", []string{"process=springboard"}, true}, + {"wrong level value still fails", []string{"level=warning"}, false}, } for _, test := range tests { @@ -171,3 +176,27 @@ func TestStreamLogsSucceedsWhenEveryEntryIsWritten(t *testing.T) { t.Errorf("expected 3 log lines, got %d", lines) } } + +func TestMatchesFiltersMessageIsCaseInsensitiveSubstring(t *testing.T) { + entry := devices.LogEntry{Message: "Connection timed out after 10s"} + + cases := []struct { + filter string + want bool + }{ + {"message=timed out", true}, + {"message=TIMED OUT", true}, + {"message=timeout", false}, + {"message=connection", true}, + {"message!=timed out", false}, + } + for _, c := range cases { + filters, err := ParseLogFilters([]string{c.filter}) + if err != nil { + t.Fatalf("unexpected error for %q: %v", c.filter, err) + } + if got := matchesFilters(entry, filters); got != c.want { + t.Errorf("matchesFilters(%q) = %v, want %v", c.filter, got, c.want) + } + } +}