diff --git a/internal/jump/jump.go b/internal/jump/jump.go new file mode 100644 index 0000000..8434851 --- /dev/null +++ b/internal/jump/jump.go @@ -0,0 +1,103 @@ +package jump + +import ( + "context" + "errors" + "fmt" + "os" + "path" + + "github.com/kryft-dev/cdd/internal/config" + "github.com/kryft-dev/cdd/internal/git" + "github.com/kryft-dev/cdd/internal/history" + "github.com/kryft-dev/cdd/internal/picker" + "github.com/kryft-dev/cdd/internal/project" +) + +// ErrCancelled is returned by Resolve when the Picker is cancelled (Esc, +// Ctrl-C, or q on an empty filter). +var ErrCancelled = errors.New("jump: cancelled") + +// PickFunc runs the Picker over rows and returns the chosen Row, mirroring +// picker.Run's signature so tests can inject a fake Picker; production +// passes picker.Run itself. +type PickFunc func(rows []picker.Row, status picker.StatusFunc, opts picker.Options) (picker.Row, bool, error) + +// Resolve runs the pick flow that turns query into the absolute path of +// the Project to Jump to: discover Projects, read History's latest Visits, +// take the exact-match shortcut on a Project's Name or Rel, otherwise run +// the Picker (via pick) with query prefilled, confirm the chosen +// directory still exists, Record the Visit, and return the absolute path. +// +// A cancelled Picker yields ErrCancelled. A Visit that fails to Record +// only prints a warning to stderr; Resolve still returns the path. +func Resolve(ctx context.Context, cfg config.Config, hist *history.History, query string, pick PickFunc) (string, error) { + projects, err := project.Discover(cfg.Root, cfg.Exclude, cfg.IncludeHidden) + if err != nil { + return "", fmt.Errorf("jump: discover projects: %w", err) + } + + latest, err := hist.Latest() + if err != nil { + return "", fmt.Errorf("jump: %w", err) + } + + rel, abs, err := choose(cfg, projects, latest, query, pick) + if err != nil { + return "", err + } + + if _, err := os.Stat(abs); err != nil { + return "", fmt.Errorf("jump: %q no longer exists: %w", abs, err) + } + + if err := hist.Record(rel); err != nil { + fmt.Fprintf(os.Stderr, "cdd: warning: recording Visit for %q: %v\n", rel, err) + } + + return abs, nil +} + +// choose picks a Project either via the exact-match shortcut or by running +// the Picker, and returns its Rel and absolute path. +func choose(cfg config.Config, projects []project.Project, latest []history.Visit, query string, pick PickFunc) (rel, abs string, err error) { + if p, ok := exactMatch(projects, query); ok { + return p.Rel(), p.Abs(cfg.Root), nil + } + + rows := order(projects, latest, cfg.Root) + status := func(c context.Context, dir string) git.Status { + s, _ := git.GetStatus(c, dir) + return s + } + + row, ok, err := pick(rows, status, picker.Options{Vim: cfg.Keys.Vim, Query: query}) + if err != nil { + return "", "", fmt.Errorf("jump: %w", err) + } + if !ok { + return "", "", ErrCancelled + } + + rel = path.Join(row.Project.Kind, row.Project.Name) + return rel, row.Project.Path, nil +} + +// exactMatch reports whether query is exactly one Project's Name or Rel. +// A query matching two or more Projects (e.g. the same Name in different +// Kinds), or none, is not an exact match. +func exactMatch(projects []project.Project, query string) (project.Project, bool) { + if query == "" { + return project.Project{}, false + } + + var found project.Project + count := 0 + for _, p := range projects { + if p.Name == query || p.Rel() == query { + found = p + count++ + } + } + return found, count == 1 +} diff --git a/internal/jump/order.go b/internal/jump/order.go new file mode 100644 index 0000000..b55a619 --- /dev/null +++ b/internal/jump/order.go @@ -0,0 +1,55 @@ +// Package jump resolves the query a Jump starts from into the chosen +// Project's absolute path: it discovers Projects, orders them by History, +// takes the exact-match shortcut, and otherwise runs the Picker. +package jump + +import ( + "sort" + "time" + + "github.com/kryft-dev/cdd/internal/history" + "github.com/kryft-dev/cdd/internal/picker" + "github.com/kryft-dev/cdd/internal/project" +) + +// order applies the ordering rule to projects, given History's latest +// Visits: Projects with a Visit come first, newest Visit first, ties break +// alphabetically by Rel; never-visited Projects follow, alphabetically. A +// Visit for a Project not present in projects (a Stale Visit) contributes +// no row. order is a pure function: it does not touch the filesystem or +// History itself. +func order(projects []project.Project, latest []history.Visit, root string) []picker.Row { + lastVisit := make(map[string]time.Time, len(latest)) + for _, v := range latest { + lastVisit[v.Project] = v.At + } + + ranked := make([]project.Project, len(projects)) + copy(ranked, projects) + + sort.SliceStable(ranked, func(i, j int) bool { + a, b := ranked[i], ranked[j] + aAt, aVisited := lastVisit[a.Rel()] + bAt, bVisited := lastVisit[b.Rel()] + if aVisited != bVisited { + return aVisited + } + if aVisited && !aAt.Equal(bAt) { + return aAt.After(bAt) + } + return a.Rel() < b.Rel() + }) + + rows := make([]picker.Row, len(ranked)) + for i, p := range ranked { + rows[i] = picker.Row{ + Project: picker.Project{ + Kind: p.Kind, + Name: p.Name, + Path: p.Abs(root), + }, + LastVisit: lastVisit[p.Rel()], + } + } + return rows +} diff --git a/internal/jump/order_test.go b/internal/jump/order_test.go new file mode 100644 index 0000000..b37172c --- /dev/null +++ b/internal/jump/order_test.go @@ -0,0 +1,115 @@ +package jump + +import ( + "testing" + "time" + + "github.com/kryft-dev/cdd/internal/history" + "github.com/kryft-dev/cdd/internal/picker" + "github.com/kryft-dev/cdd/internal/project" +) + +// TestOrder_VisitedFirstNewestThenNeverVisited checks the ordering rule: +// Projects with a Visit come first, newest Visit first, ties break +// alphabetically by Rel, and never-visited Projects follow, alphabetically. +func TestOrder_VisitedFirstNewestThenNeverVisited(t *testing.T) { + projects := []project.Project{ + {Kind: "tools", Name: "cdd"}, + {Kind: "tools", Name: "dotfiles"}, + {Kind: "oss", Name: "lib"}, + {Kind: "work", Name: "api"}, + {Kind: "archive", Name: "old"}, + } + + latest := []history.Visit{ + {Project: "tools/cdd", At: time.Unix(3000, 0)}, + {Project: "work/api", At: time.Unix(5000, 0)}, + {Project: "oss/lib", At: time.Unix(1000, 0)}, + } + + got := order(projects, latest, "/root") + + want := []string{ + "work/api", // newest Visit + "tools/cdd", // next newest Visit + "oss/lib", // oldest Visit + "archive/old", // never visited, alphabetical + "tools/dotfiles", // never visited, alphabetical + } + + assertRowOrder(t, got, want) +} + +// TestOrder_TiesBreakAlphabetically checks that Projects sharing the exact +// same Visit timestamp sort alphabetically by Rel among themselves. +func TestOrder_TiesBreakAlphabetically(t *testing.T) { + projects := []project.Project{ + {Kind: "tools", Name: "zeta"}, + {Kind: "tools", Name: "alpha"}, + } + + tie := time.Unix(1000, 0) + latest := []history.Visit{ + {Project: "tools/zeta", At: tie}, + {Project: "tools/alpha", At: tie}, + } + + got := order(projects, latest, "/root") + + want := []string{"tools/alpha", "tools/zeta"} + assertRowOrder(t, got, want) +} + +// TestOrder_NeverVisitedAlphabetical checks that, with no Visits at all, +// order falls back to plain alphabetical by Rel. +func TestOrder_NeverVisitedAlphabetical(t *testing.T) { + projects := []project.Project{ + {Kind: "tools", Name: "zeta"}, + {Kind: "archive", Name: "old"}, + {Kind: "tools", Name: "alpha"}, + } + + got := order(projects, nil, "/root") + + want := []string{"archive/old", "tools/alpha", "tools/zeta"} + assertRowOrder(t, got, want) +} + +// TestOrder_StaleVisitContributesNoRow checks that a Visit for a Project no +// longer discovered (a Stale Visit) does not appear as a row, and that the +// remaining rows order as if it never existed. +func TestOrder_StaleVisitContributesNoRow(t *testing.T) { + projects := []project.Project{ + {Kind: "tools", Name: "cdd"}, + } + + latest := []history.Visit{ + {Project: "tools/cdd", At: time.Unix(1000, 0)}, + {Project: "gone/vanished", At: time.Unix(9000, 0)}, + } + + got := order(projects, latest, "/root") + + if len(got) != 1 { + t.Fatalf("order: got %d rows, want 1", len(got)) + } + if got[0].Project.Kind != "tools" || got[0].Project.Name != "cdd" { + t.Errorf("row = %+v, want tools/cdd", got[0]) + } +} + +// assertRowOrder checks got's rows, in order, have Rel (Kind/Name) matching +// want. +func assertRowOrder(t *testing.T, got []picker.Row, want []string) { + t.Helper() + + if len(got) != len(want) { + t.Fatalf("order: got %d rows, want %d", len(got), len(want)) + } + for i, w := range want { + rel := got[i].Project.Kind + "/" + got[i].Project.Name + if rel != w { + t.Errorf("row %d = %q, want %q", i, rel, w) + } + } +} diff --git a/internal/jump/resolve_test.go b/internal/jump/resolve_test.go new file mode 100644 index 0000000..a948968 --- /dev/null +++ b/internal/jump/resolve_test.go @@ -0,0 +1,212 @@ +package jump_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/kryft-dev/cdd/internal/config" + "github.com/kryft-dev/cdd/internal/history" + "github.com/kryft-dev/cdd/internal/jump" + "github.com/kryft-dev/cdd/internal/picker" +) + +// mkProjects creates each rel path as a Kind/Name directory tree under +// root, and returns a Config pointing at root. +func mkProjects(t *testing.T, rels ...string) (config.Config, string) { + t.Helper() + + root := t.TempDir() + for _, rel := range rels { + if err := os.MkdirAll(filepath.Join(root, rel), 0o755); err != nil { + t.Fatalf("MkdirAll(%q): %v", rel, err) + } + } + + return config.Config{Root: root, History: config.History{MaxVisits: 1000}}, root +} + +// newHistory opens a fresh History in a temp directory. +func newHistory(t *testing.T) *history.History { + t.Helper() + + path := filepath.Join(t.TempDir(), "history") + h, err := history.Open(path, 1000) + if err != nil { + t.Fatalf("history.Open: %v", err) + } + return h +} + +// failPick fails the test if the Picker is ever run; it is used by tests +// that expect the exact-match shortcut to skip it. +func failPick(t *testing.T) jump.PickFunc { + t.Helper() + return func(rows []picker.Row, status picker.StatusFunc, opts picker.Options) (picker.Row, bool, error) { + t.Fatal("pick: Picker was run, want the exact-match shortcut to skip it") + return picker.Row{}, false, nil + } +} + +func TestResolve_ExactNameShortcutSkipsPicker(t *testing.T) { + cfg, root := mkProjects(t, "tools/cdd") + hist := newHistory(t) + + got, err := jump.Resolve(context.Background(), cfg, hist, "cdd", failPick(t)) + if err != nil { + t.Fatalf("Resolve: unexpected error: %v", err) + } + + want := filepath.Join(root, "tools", "cdd") + if got != want { + t.Errorf("Resolve = %q, want %q", got, want) + } + + assertRecorded(t, hist, "tools/cdd") +} + +func TestResolve_ExactKindNameShortcutSkipsPicker(t *testing.T) { + cfg, root := mkProjects(t, "tools/cdd", "archive/cdd") + hist := newHistory(t) + + got, err := jump.Resolve(context.Background(), cfg, hist, "tools/cdd", failPick(t)) + if err != nil { + t.Fatalf("Resolve: unexpected error: %v", err) + } + + want := filepath.Join(root, "tools", "cdd") + if got != want { + t.Errorf("Resolve = %q, want %q", got, want) + } + + assertRecorded(t, hist, "tools/cdd") +} + +func TestResolve_AmbiguousNameOpensPickerPrefilled(t *testing.T) { + cfg, root := mkProjects(t, "tools/cdd", "archive/cdd") + hist := newHistory(t) + + var gotQuery string + var gotRows int + pick := func(rows []picker.Row, status picker.StatusFunc, opts picker.Options) (picker.Row, bool, error) { + gotQuery = opts.Query + gotRows = len(rows) + return picker.Row{Project: picker.Project{ + Kind: "tools", Name: "cdd", Path: filepath.Join(root, "tools", "cdd"), + }}, true, nil + } + + got, err := jump.Resolve(context.Background(), cfg, hist, "cdd", pick) + if err != nil { + t.Fatalf("Resolve: unexpected error: %v", err) + } + + if gotQuery != "cdd" { + t.Errorf("Picker Query = %q, want %q", gotQuery, "cdd") + } + if gotRows != 2 { + t.Errorf("Picker got %d rows, want 2 (both Projects named cdd)", gotRows) + } + + want := filepath.Join(root, "tools", "cdd") + if got != want { + t.Errorf("Resolve = %q, want %q", got, want) + } + + assertRecorded(t, hist, "tools/cdd") +} + +func TestResolve_NoMatchOpensPickerPrefilled(t *testing.T) { + cfg, root := mkProjects(t, "tools/cdd") + hist := newHistory(t) + + var gotQuery string + pick := func(rows []picker.Row, status picker.StatusFunc, opts picker.Options) (picker.Row, bool, error) { + gotQuery = opts.Query + return picker.Row{Project: picker.Project{ + Kind: "tools", Name: "cdd", Path: filepath.Join(root, "tools", "cdd"), + }}, true, nil + } + + if _, err := jump.Resolve(context.Background(), cfg, hist, "nope", pick); err != nil { + t.Fatalf("Resolve: unexpected error: %v", err) + } + if gotQuery != "nope" { + t.Errorf("Picker Query = %q, want %q", gotQuery, "nope") + } +} + +func TestResolve_CancelReturnsErrCancelled(t *testing.T) { + cfg, _ := mkProjects(t, "tools/cdd") + hist := newHistory(t) + + pick := func(rows []picker.Row, status picker.StatusFunc, opts picker.Options) (picker.Row, bool, error) { + return picker.Row{}, false, nil + } + + _, err := jump.Resolve(context.Background(), cfg, hist, "anything", pick) + if !errors.Is(err, jump.ErrCancelled) { + t.Fatalf("Resolve error = %v, want ErrCancelled", err) + } +} + +func TestResolve_VanishedDirectoryErrors(t *testing.T) { + cfg, root := mkProjects(t, "tools/cdd") + hist := newHistory(t) + + gone := filepath.Join(root, "tools", "vanished") + pick := func(rows []picker.Row, status picker.StatusFunc, opts picker.Options) (picker.Row, bool, error) { + return picker.Row{Project: picker.Project{Kind: "tools", Name: "vanished", Path: gone}}, true, nil + } + + _, err := jump.Resolve(context.Background(), cfg, hist, "anything", pick) + if err == nil { + t.Fatal("Resolve: want error for a chosen directory that no longer exists, got nil") + } +} + +func TestResolve_FailingHistoryWriteStillReturnsPath(t *testing.T) { + cfg, root := mkProjects(t, "tools/cdd") + + historyDir := t.TempDir() + historyPath := filepath.Join(historyDir, "history") + hist, err := history.Open(historyPath, 1000) + if err != nil { + t.Fatalf("history.Open: %v", err) + } + + // Force Record to fail by making its directory unwritable, without + // touching the Project directories under root. + if err := os.Chmod(historyDir, 0o500); err != nil { + t.Fatalf("Chmod: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(historyDir, 0o700) }) + + got, err := jump.Resolve(context.Background(), cfg, hist, "cdd", failPick(t)) + if err != nil { + t.Fatalf("Resolve: unexpected error despite a failing History write: %v", err) + } + + want := filepath.Join(root, "tools", "cdd") + if got != want { + t.Errorf("Resolve = %q, want %q", got, want) + } +} + +// assertRecorded checks History's latest Visits contain rel. +func assertRecorded(t *testing.T, hist *history.History, rel string) { + t.Helper() + + latest, err := hist.Latest() + if err != nil { + t.Fatalf("Latest: %v", err) + } + for _, v := range latest { + if v.Project == rel { + return + } + } + t.Errorf("History has no Visit for %q after Resolve", rel) +}