diff --git a/cmd/dispatch/export.go b/cmd/dispatch/export.go index d88d8af..5f5ffad 100644 --- a/cmd/dispatch/export.go +++ b/cmd/dispatch/export.go @@ -242,7 +242,9 @@ func writeExportFile(dir, id, format, content string) (string, error) { } // defaultExportGetDetail loads a full session detail by ID from the default -// session store. It returns (nil, nil) when no session with that ID exists. +// session store. The ID may be a full session ID or a unique short prefix. It +// returns (nil, nil) when no session matches, and an *data.AmbiguousIDPrefixError +// when a short prefix matches more than one session. func defaultExportGetDetail(id string) (*data.SessionDetail, error) { store, err := data.Open() if err != nil { @@ -250,7 +252,16 @@ func defaultExportGetDetail(id string) (*data.SessionDetail, error) { } defer store.Close() //nolint:errcheck // read-only, best-effort close - detail, err := store.GetSession(context.Background(), id) + ctx := context.Background() + fullID, err := store.ResolveIDPrefix(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + + detail, err := store.GetSession(ctx, fullID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index 7ed63aa..804df68 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -115,7 +115,7 @@ Usage: Commands: help Show this help message version [--json] Print the version - open [--mode M] Resume a session by ID (M: inplace, tab, window, pane) + open [--mode M] Resume a session by ID or prefix (M: inplace, tab, window, pane) --print writes the resume command instead of launching open --last [--mode M] Resume the most recently active session new [dir] [--mode M] Start a new session in a directory (default: current) @@ -129,6 +129,11 @@ Commands: export [flags] Export a session as Markdown or JSON update Update dispatch to the latest release +Session IDs: + Commands that take (open, export) accept a full session ID or any + unique prefix of one, like a short git SHA. An ambiguous prefix lists the + matching sessions so you can add more characters. + Stats flags: --json Print the summary as JSON --calendar Add a per-day activity heatmap diff --git a/cmd/dispatch/open.go b/cmd/dispatch/open.go index 7611339..6030f33 100644 --- a/cmd/dispatch/open.go +++ b/cmd/dispatch/open.go @@ -183,7 +183,16 @@ func defaultOpenGetSession(id string) (*data.Session, error) { } defer store.Close() //nolint:errcheck // read-only, best-effort close - detail, err := store.GetSession(context.Background(), id) + ctx := context.Background() + fullID, err := store.ResolveIDPrefix(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + + detail, err := store.GetSession(ctx, fullID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil diff --git a/internal/data/store.go b/internal/data/store.go index a320712..7732a32 100644 --- a/internal/data/store.go +++ b/internal/data/store.go @@ -1008,6 +1008,77 @@ func (s *Store) CountSessions(ctx context.Context) (int, error) { return n, nil } +// resolveIDPrefixLimit caps how many candidate IDs an ambiguous prefix lists +// so an error message stays readable when a short prefix matches many sessions. +const resolveIDPrefixLimit = 10 + +// AmbiguousIDPrefixError is returned by ResolveIDPrefix when a session ID +// prefix matches more than one stored session. Candidates lists the matching +// full IDs (capped) so the caller can tell the user how to disambiguate. +type AmbiguousIDPrefixError struct { + Prefix string + Candidates []string +} + +func (e *AmbiguousIDPrefixError) Error() string { + return fmt.Sprintf("session ID prefix %q is ambiguous; add more characters to match one of: %s", + e.Prefix, strings.Join(e.Candidates, ", ")) +} + +// ResolveIDPrefix expands a session ID or a unique ID prefix to a full session +// ID, mirroring git's short-SHA behavior. An exact ID match always wins, even +// when that value is also a prefix of longer IDs. With no exact match the value +// is treated as a prefix: a single match returns that ID, multiple matches +// return an *AmbiguousIDPrefixError listing the candidates, and no match +// returns sql.ErrNoRows. An empty prefix is rejected. +func (s *Store) ResolveIDPrefix(ctx context.Context, prefix string) (string, error) { + if prefix == "" { + return "", errors.New("session ID prefix is empty") + } + + // An exact match short-circuits: a full ID beats any prefix collision. + var exact string + err := s.db.QueryRowContext(ctx, "SELECT id FROM sessions WHERE id = ?", prefix).Scan(&exact) + switch { + case err == nil: + return exact, nil + case !errors.Is(err, sql.ErrNoRows): + return "", fmt.Errorf("looking up session %q: %w", prefix, err) + } + + // No exact match: resolve as a prefix. Fetch one more than the display cap + // so the candidate list stays bounded even for very short prefixes. + pattern := escapeLIKE(prefix) + "%" + rows, err := s.db.QueryContext(ctx, + `SELECT id FROM sessions WHERE id LIKE ? ESCAPE '\' ORDER BY id LIMIT ?`, + pattern, resolveIDPrefixLimit+1) + if err != nil { + return "", fmt.Errorf("resolving session ID prefix %q: %w", prefix, err) + } + defer closeRows(rows) + + var candidates []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return "", fmt.Errorf("scanning session ID: %w", err) + } + candidates = append(candidates, id) + } + if err := rows.Err(); err != nil { + return "", fmt.Errorf("resolving session ID prefix %q: %w", prefix, err) + } + + switch len(candidates) { + case 0: + return "", sql.ErrNoRows + case 1: + return candidates[0], nil + default: + return "", &AmbiguousIDPrefixError{Prefix: prefix, Candidates: candidates} + } +} + // GroupSessions groups sessions by the specified pivot field, applying the // given filter and sort order within each group. func (s *Store) GroupSessions(ctx context.Context, pivot PivotField, filter FilterOptions, sort SortOptions, limit int) ([]SessionGroup, error) { diff --git a/internal/data/store_prefix_test.go b/internal/data/store_prefix_test.go new file mode 100644 index 0000000..5cdef33 --- /dev/null +++ b/internal/data/store_prefix_test.go @@ -0,0 +1,158 @@ +package data + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "testing" +) + +// seedID inserts a bare session with just an ID; the other columns are not +// relevant to prefix resolution. +func seedID(t *testing.T, s *Store, id string) { + t.Helper() + seedSession(t, s.db, id, "", "", "", "", "2024-01-01T00:00:00Z", "2024-01-01T00:00:00Z") +} + +func TestResolveIDPrefix_ExactMatch(t *testing.T) { + s := newTestStore(t) + defer s.Close() //nolint:errcheck // test cleanup + seedID(t, s, "abcdef123456") + + got, err := s.ResolveIDPrefix(context.Background(), "abcdef123456") + if err != nil { + t.Fatalf("ResolveIDPrefix returned error: %v", err) + } + if got != "abcdef123456" { + t.Errorf("got %q, want %q", got, "abcdef123456") + } +} + +func TestResolveIDPrefix_UniquePrefix(t *testing.T) { + s := newTestStore(t) + defer s.Close() //nolint:errcheck // test cleanup + seedID(t, s, "abcdef123456") + seedID(t, s, "zzz999") + + got, err := s.ResolveIDPrefix(context.Background(), "abc") + if err != nil { + t.Fatalf("ResolveIDPrefix returned error: %v", err) + } + if got != "abcdef123456" { + t.Errorf("got %q, want %q", got, "abcdef123456") + } +} + +// TestResolveIDPrefix_ExactWinsOverPrefix verifies git-style behavior: a value +// that is both a full ID and a prefix of longer IDs resolves to the exact ID. +func TestResolveIDPrefix_ExactWinsOverPrefix(t *testing.T) { + s := newTestStore(t) + defer s.Close() //nolint:errcheck // test cleanup + seedID(t, s, "abc") + seedID(t, s, "abcdef") + + got, err := s.ResolveIDPrefix(context.Background(), "abc") + if err != nil { + t.Fatalf("ResolveIDPrefix returned error: %v", err) + } + if got != "abc" { + t.Errorf("got %q, want exact match %q", got, "abc") + } +} + +func TestResolveIDPrefix_Ambiguous(t *testing.T) { + s := newTestStore(t) + defer s.Close() //nolint:errcheck // test cleanup + seedID(t, s, "abcdef") + seedID(t, s, "abcxyz") + + _, err := s.ResolveIDPrefix(context.Background(), "abc") + if err == nil { + t.Fatal("expected ambiguity error, got nil") + } + var ambErr *AmbiguousIDPrefixError + if !errors.As(err, &ambErr) { + t.Fatalf("expected *AmbiguousIDPrefixError, got %T: %v", err, err) + } + if ambErr.Prefix != "abc" { + t.Errorf("Prefix = %q, want %q", ambErr.Prefix, "abc") + } + if len(ambErr.Candidates) != 2 { + t.Fatalf("Candidates = %v, want 2 entries", ambErr.Candidates) + } + // Candidates are sorted by ID. + if ambErr.Candidates[0] != "abcdef" || ambErr.Candidates[1] != "abcxyz" { + t.Errorf("Candidates = %v, want [abcdef abcxyz]", ambErr.Candidates) + } + // The message should name the prefix and both candidates. + msg := ambErr.Error() + for _, want := range []string{"abc", "abcdef", "abcxyz"} { + if !strings.Contains(msg, want) { + t.Errorf("error message %q should contain %q", msg, want) + } + } +} + +func TestResolveIDPrefix_NoMatch(t *testing.T) { + s := newTestStore(t) + defer s.Close() //nolint:errcheck // test cleanup + seedID(t, s, "abcdef") + + _, err := s.ResolveIDPrefix(context.Background(), "zzz") + if !errors.Is(err, sql.ErrNoRows) { + t.Errorf("expected sql.ErrNoRows, got %v", err) + } +} + +func TestResolveIDPrefix_Empty(t *testing.T) { + s := newTestStore(t) + defer s.Close() //nolint:errcheck // test cleanup + + _, err := s.ResolveIDPrefix(context.Background(), "") + if err == nil { + t.Fatal("expected error for empty prefix, got nil") + } + if errors.Is(err, sql.ErrNoRows) { + t.Errorf("empty prefix should not report ErrNoRows, got %v", err) + } +} + +// TestResolveIDPrefix_LiteralWildcards verifies that LIKE wildcard characters +// in the prefix are matched literally rather than as patterns. +func TestResolveIDPrefix_LiteralWildcards(t *testing.T) { + s := newTestStore(t) + defer s.Close() //nolint:errcheck // test cleanup + seedID(t, s, "a_b123") + seedID(t, s, "axb456") + + // "a_" must match only "a_b123"; if the underscore were treated as a LIKE + // wildcard it would also match "axb456" and report an ambiguity. + got, err := s.ResolveIDPrefix(context.Background(), "a_") + if err != nil { + t.Fatalf("ResolveIDPrefix returned error: %v", err) + } + if got != "a_b123" { + t.Errorf("got %q, want %q", got, "a_b123") + } +} + +// TestResolveIDPrefix_CandidateCap verifies the ambiguity candidate list is +// bounded so error messages stay readable with many matches. +func TestResolveIDPrefix_CandidateCap(t *testing.T) { + s := newTestStore(t) + defer s.Close() //nolint:errcheck // test cleanup + for i := 0; i < resolveIDPrefixLimit+5; i++ { + seedID(t, s, fmt.Sprintf("z%03d", i)) + } + + _, err := s.ResolveIDPrefix(context.Background(), "z") + var ambErr *AmbiguousIDPrefixError + if !errors.As(err, &ambErr) { + t.Fatalf("expected *AmbiguousIDPrefixError, got %T: %v", err, err) + } + if len(ambErr.Candidates) != resolveIDPrefixLimit+1 { + t.Errorf("Candidates length = %d, want %d", len(ambErr.Candidates), resolveIDPrefixLimit+1) + } +}