diff --git a/commands/grep.go b/commands/grep.go index f30f2d8..ef16ab1 100644 --- a/commands/grep.go +++ b/commands/grep.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "strconv" + "strings" "time" "github.com/gookit/color" @@ -93,14 +94,14 @@ func commandGrep(c *cli.Context) error { return fmt.Errorf("unsupported format: %s. Use 'table' or 'json'", format) } - // Get search text from args - searchText := c.Args().First() + // Get search text from args; unquoted words form one phrase (rg git sub == rg "git sub") + searchText := strings.TrimSpace(strings.Join(c.Args().Slice(), " ")) slog.Debug("grep command args", - slog.String("first", searchText), + slog.String("searchText", searchText), slog.Int("nArgs", c.NArg()), slog.Any("allArgs", c.Args().Slice())) if searchText == "" { - return fmt.Errorf("search text is required. Usage: shelltime grep ") + return fmt.Errorf("search text is required. Usage: shelltime rg ") } // Read config to get endpoint and token @@ -209,24 +210,27 @@ func buildGrepFilter(c *cli.Context, searchText string) (*model.SearchCommandsFi filter.MainCommand = []string{mainCmd} } - // Handle time filters with flexible date parsing - var timeFilters []float64 - if since := c.String("since"); since != "" { - t, err := parseFlexibleDate(since, false) - if err != nil { - return nil, fmt.Errorf("invalid --since date: %w", err) + // Handle time filters with flexible date parsing. The server expects a + // [start, end] pair in unix seconds; a single value means "that one day", + // so an open side is filled in (epoch for start, now for end). + since, until := c.String("since"), c.String("until") + if since != "" || until != "" { + start, end := time.Unix(0, 0), time.Now() + if since != "" { + t, err := parseFlexibleDate(since, false) + if err != nil { + return nil, fmt.Errorf("invalid --since date: %w", err) + } + start = t } - timeFilters = append(timeFilters, float64(t.UnixMilli())) - } - if until := c.String("until"); until != "" { - t, err := parseFlexibleDate(until, true) - if err != nil { - return nil, fmt.Errorf("invalid --until date: %w", err) + if until != "" { + t, err := parseFlexibleDate(until, true) + if err != nil { + return nil, fmt.Errorf("invalid --until date: %w", err) + } + end = t } - timeFilters = append(timeFilters, float64(t.UnixMilli())) - } - if len(timeFilters) > 0 { - filter.Time = timeFilters + filter.Time = []float64{float64(start.Unix()), float64(end.Unix())} } return filter, nil diff --git a/commands/grep_test.go b/commands/grep_test.go index c1a03f8..eec8629 100644 --- a/commands/grep_test.go +++ b/commands/grep_test.go @@ -174,12 +174,40 @@ func TestBuildGrepFilter_SinceAndUntilTimeWindow(t *testing.T) { filter, err := buildGrepFilter(c, "x") require.NoError(t, err) + // The server reads the window in unix seconds. require.Len(t, filter.Time, 2) since := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - assert.Equal(t, float64(since.UnixMilli()), filter.Time[0]) + assert.Equal(t, float64(since.Unix()), filter.Time[0]) // until=2024-06 end-of-period -> 2024-06-30 23:59:59 UTC. untilEnd := time.Date(2024, 6, 30, 23, 59, 59, 0, time.UTC) - assert.Equal(t, float64(untilEnd.UnixMilli()), filter.Time[1]) + assert.Equal(t, float64(untilEnd.Unix()), filter.Time[1]) +} + +func TestBuildGrepFilter_SinceOnlyRunsUntilNow(t *testing.T) { + c := newGrepContext(t, func(fs *flag.FlagSet) { + require.NoError(t, fs.Set("since", "2024-01-15")) + }) + filter, err := buildGrepFilter(c, "x") + require.NoError(t, err) + + // A single value would be read as "that one day", so the end is filled in. + require.Len(t, filter.Time, 2) + since := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) + assert.Equal(t, float64(since.Unix()), filter.Time[0]) + assert.InDelta(t, float64(time.Now().Unix()), filter.Time[1], 5) +} + +func TestBuildGrepFilter_UntilOnlyStartsAtEpoch(t *testing.T) { + c := newGrepContext(t, func(fs *flag.FlagSet) { + require.NoError(t, fs.Set("until", "2024-01-15")) + }) + filter, err := buildGrepFilter(c, "x") + require.NoError(t, err) + + require.Len(t, filter.Time, 2) + assert.Equal(t, float64(0), filter.Time[0]) + untilEnd := time.Date(2024, 1, 15, 23, 59, 59, 0, time.UTC) + assert.Equal(t, float64(untilEnd.Unix()), filter.Time[1]) } func TestBuildGrepFilter_InvalidSince(t *testing.T) { @@ -327,6 +355,34 @@ func TestCommandGrep_SuccessJSON(t *testing.T) { assert.True(t, strings.Contains(gotBody, "fetchCommands"), "request body should contain the query") } +func TestCommandGrep_UnquotedWordsFormOnePhrase(t *testing.T) { + mc := setupGrepActionTest(t) + + var gotFilter model.SearchCommandsFilter + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Variables struct { + Filter model.SearchCommandsFilter `json:"filter"` + } `json:"variables"` + } + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + gotFilter = body.Variables.Filter + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":{"fetchCommands":{"count":0,"edges":[]}}}`) + })) + t.Cleanup(server.Close) + + mc.On("ReadConfigFile", mock.Anything).Return(model.ShellTimeConfig{ + Token: "tok", + APIEndpoint: server.URL, + }, nil) + + app := &cli.App{Name: "t", Commands: []*cli.Command{GrepCommand}} + // `shelltime rg git sub` must search "git sub", not just "git". + require.NoError(t, app.Run([]string{"t", "rg", "git", "sub"})) + assert.Equal(t, "git sub", gotFilter.Command) +} + func TestCommandGrep_NoResults(t *testing.T) { mc := setupGrepActionTest(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {