diff --git a/go.mod b/go.mod index a6d3485..078b54a 100644 --- a/go.mod +++ b/go.mod @@ -18,10 +18,13 @@ require ( github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/mattn/go-runewidth v0.0.27 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect diff --git a/go.sum b/go.sum index 38ce4b3..6fa99e6 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,9 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= @@ -34,13 +37,20 @@ github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdD github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 6279787..dc6b53d 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1,40 +1,116 @@ -// Package cli is the stub command-line entry point for cdd. It handles the -// "version" subcommand only; full CLI wiring (cobra, Root, Kind, Project, -// Jump, Visit, History, Scan, Picker, Wrapper commands) lands in a later -// ticket. +// Package cli wires cdd's command surface with github.com/spf13/cobra: the +// root command (bare behaves as pick), pick, init, scan, version, plus +// cobra's own help and completion commands. package cli import ( + "errors" "fmt" + "io" "os" -) + "strings" + + "github.com/spf13/cobra" -// version, commit, and date are set at build time via -ldflags. They default -// to values that make an unflagged build identify itself as a dev build. -var ( - version = "dev" - commit = "" - date = "" + "github.com/kryft-dev/cdd/internal/jump" ) -// Execute runs the CLI and returns the process exit code. It handles -// "version" and prints a usage line to stderr for anything else. +// Execute runs the CLI against os.Args and returns the process exit code. func Execute() int { - args := os.Args[1:] - if len(args) == 1 && args[0] == "version" { - fmt.Println(versionString()) + return Run(os.Args[1:], os.Stdout, os.Stderr) +} + +// Run parses args against the cdd command tree, writing to stdout and +// stderr, and returns the process exit code: 0 on success, 130 silently on +// a cancelled Picker (jump.ErrCancelled), 1 with a stderr message on any +// other error, 2 with a stderr message on a usage error. +// +// Run is exported, rather than Execute alone, so tests can drive the CLI +// in-process with cobra's SetArgs and captured stdout/stderr instead of +// exec'ing a built binary. +func Run(args []string, stdout, stderr io.Writer) int { + root := newRootCmd() + root.SetArgs(args) + root.SetOut(stdout) + root.SetErr(stderr) + root.SilenceUsage = true + root.SilenceErrors = true + + err := root.Execute() + if err == nil { return 0 } + if errors.Is(err, jump.ErrCancelled) { + return 130 + } - fmt.Fprintln(os.Stderr, "usage: cdd version") - return 2 + fmt.Fprintln(stderr, err) + if isUsageError(err) { + return 2 + } + return 1 } -// versionString formats the version, commit, and date set by ldflags. When -// none of them were set, it reports "cdd dev (dev)". -func versionString() string { - if version == "dev" && commit == "" && date == "" { - return "cdd dev (dev)" +// newRootCmd builds the cdd command tree: root (bare behaves as pick), +// pick, init, scan, version, plus cobra's help and completion commands. +func newRootCmd() *cobra.Command { + root := &cobra.Command{ + Use: "cdd", + Short: "cdd finds Projects under a Root and Jumps to the one you pick", + } + root.Flags().BoolP("version", "V", false, "print the cdd version and exit") + + root.RunE = func(cmd *cobra.Command, args []string) error { + if v, _ := cmd.Flags().GetBool("version"); v { + fmt.Fprintln(cmd.OutOrStdout(), versionString()) + return nil + } + return pickRunE(cmd, nil) + } + + root.AddCommand(newPickCmd(), newInitCmd(), newScanCmd(), newVersionCmd()) + + // Initialized here, not left to Execute, so the command tree is + // complete (including "help" and "completion") whenever init's + // passthrough list is derived from it, even outside Execute. + root.InitDefaultHelpCmd() + root.InitDefaultCompletionCmd() + + return root +} + +// usageError marks err as a usage error: Run reports it with exit code 2 +// instead of the default 1. +type usageError struct{ error } + +// newUsageError wraps err so isUsageError recognizes it as a usage error. +func newUsageError(err error) error { return usageError{err} } + +// isUsageError reports whether err should exit 2: either a command's own +// usageError, or one of cobra's own argument-parsing errors (an unknown +// command or an unknown flag). +func isUsageError(err error) bool { + var ue usageError + if errors.As(err, &ue) { + return true + } + msg := err.Error() + return strings.HasPrefix(msg, "unknown command") || + strings.HasPrefix(msg, "unknown flag") || + strings.HasPrefix(msg, "unknown shorthand flag") || + strings.HasPrefix(msg, "accepts ") +} + +// isTerminal reports whether w is a terminal, so pickRunE knows whether to +// print the "cdd init " hint. +func isTerminal(w io.Writer) bool { + f, ok := w.(*os.File) + if !ok { + return false + } + info, err := f.Stat() + if err != nil { + return false } - return fmt.Sprintf("cdd %s (%s %s)", version, commit, date) + return info.Mode()&os.ModeCharDevice != 0 } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 4efc1a8..b8c9a39 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1,72 +1,183 @@ package cli_test import ( - "os/exec" + "bytes" "strings" "testing" + + "github.com/kryft-dev/cdd/internal/cli" ) -// TestVersionString builds the cdd binary and runs "cdd version" to check -// the unflagged (dev) version output, since versionString's package-level -// vars are only reachable through the built binary or by duplicating the -// build's ldflags in-process. -func TestVersionString(t *testing.T) { +// runCLI drives cli.Run in-process with args and returns the exit code +// plus captured stdout and stderr. +func runCLI(t *testing.T, args ...string) (code int, stdout, stderr string) { + t.Helper() + + var out, err bytes.Buffer + code = cli.Run(args, &out, &err) + return code, out.String(), err.String() +} + +// TestVersion checks "cdd version" and the "cdd --version" / "cdd -V" +// aliases all print the same dev-build version line and exit 0. +func TestVersion(t *testing.T) { tests := []struct { name string - want string + args []string }{ - { - name: "dev build prints dev version", - want: "cdd dev (dev)\n", - }, + {name: "version subcommand", args: []string{"version"}}, + {name: "long flag", args: []string{"--version"}}, + {name: "short flag", args: []string{"-V"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + code, stdout, stderr := runCLI(t, tt.args...) + if code != 0 { + t.Errorf("exit code = %d, want 0 (stderr: %q)", code, stderr) + } + want := "cdd dev (dev)\n" + if stdout != want { + t.Errorf("stdout = %q, want %q", stdout, want) + } + }) + } +} + +// TestUnknownCommand checks that an unrecognized subcommand exits 2 with a +// usage message on stderr and nothing on stdout. +func TestUnknownCommand(t *testing.T) { + code, stdout, stderr := runCLI(t, "bogus") + + if code != 2 { + t.Errorf("exit code = %d, want 2", code) + } + if stdout != "" { + t.Errorf("stdout = %q, want empty", stdout) } + if !strings.Contains(stderr, "unknown command") { + t.Errorf("stderr = %q, want it to contain %q", stderr, "unknown command") + } +} + +// TestUnknownFlag checks that an unrecognized flag exits 2. +func TestUnknownFlag(t *testing.T) { + code, _, stderr := runCLI(t, "--bogus") - bin := buildCDD(t) + if code != 2 { + t.Errorf("exit code = %d, want 2 (stderr: %q)", code, stderr) + } +} + +// TestInitUsageErrors checks "cdd init" exits 2, listing the supported +// shells, when given no argument, more than one argument, or an +// unsupported shell. +func TestInitUsageErrors(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "no shell", args: []string{"init"}}, + {name: "two shells", args: []string{"init", "fish", "bash"}}, + {name: "unsupported shell", args: []string{"init", "powershell"}}, + } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - out, err := exec.Command(bin, "version").CombinedOutput() - if err != nil { - t.Fatalf("cdd version: %v (output: %q)", err, out) + code, stdout, stderr := runCLI(t, tt.args...) + + if code != 2 { + t.Errorf("exit code = %d, want 2", code) } - if got := string(out); got != tt.want { - t.Errorf("cdd version = %q, want %q", got, tt.want) + if stdout != "" { + t.Errorf("stdout = %q, want empty", stdout) + } + for _, shell := range []string{"fish", "bash", "zsh"} { + if !strings.Contains(stderr, shell) { + t.Errorf("stderr = %q, want it to list %q", stderr, shell) + } } }) } } -// TestExecuteUnknownCommand checks that an unrecognized subcommand exits 2 -// with a usage line on stderr. -func TestExecuteUnknownCommand(t *testing.T) { - bin := buildCDD(t) +// TestInitPassthrough checks that "cdd init fish" prints a script naming +// every top-level command except "pick" in its pass-through list, derived +// from the cobra command tree. +func TestInitPassthrough(t *testing.T) { + code, stdout, stderr := runCLI(t, "init", "fish") - cmd := exec.Command(bin, "bogus") - var stderr strings.Builder - cmd.Stderr = &stderr + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %q)", code, stderr) + } - err := cmd.Run() - exitErr, ok := err.(*exec.ExitError) - if !ok { - t.Fatalf("cdd bogus: expected *exec.ExitError, got %v", err) + for _, name := range []string{"init", "scan", "version", "help", "completion"} { + if !strings.Contains(stdout, name) { + t.Errorf("script does not mention pass-through command %q:\n%s", name, stdout) + } } - if exitErr.ExitCode() != 2 { - t.Errorf("exit code = %d, want 2", exitErr.ExitCode()) + + // "pick" itself is the Wrapper's Jump path, not a pass-through name; + // it must not appear in the case pattern the Wrapper switches on. + if strings.Contains(stdout, "case pick") { + t.Errorf("script lists \"pick\" as a pass-through command:\n%s", stdout) } - if !strings.Contains(stderr.String(), "usage:") { - t.Errorf("stderr = %q, want it to contain %q", stderr.String(), "usage:") +} + +// TestInitEachShell checks that "cdd init" succeeds for each supported +// shell and produces non-empty output. +func TestInitEachShell(t *testing.T) { + for _, shell := range []string{"fish", "bash", "zsh"} { + t.Run(shell, func(t *testing.T) { + code, stdout, stderr := runCLI(t, "init", shell) + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %q)", code, stderr) + } + if stdout == "" { + t.Error("stdout is empty, want a rendered Wrapper script") + } + }) } } -// buildCDD builds the cmd/cdd binary into a temp directory and returns its -// path. -func buildCDD(t *testing.T) string { - t.Helper() +// TestPickUsageError checks that "cdd pick" with more than one argument +// exits 2. +func TestPickUsageError(t *testing.T) { + code, _, _ := runCLI(t, "pick", "one", "two") + if code != 2 { + t.Errorf("exit code = %d, want 2", code) + } +} - bin := t.TempDir() + "/cdd" - build := exec.Command("go", "build", "-o", bin, "../../cmd/cdd") - if out, err := build.CombinedOutput(); err != nil { - t.Fatalf("go build cmd/cdd: %v (output: %q)", err, out) +// TestScanUsageError checks that "cdd scan" with an argument exits 2. +func TestScanUsageError(t *testing.T) { + code, _, _ := runCLI(t, "scan", "extra") + if code != 2 { + t.Errorf("exit code = %d, want 2", code) + } +} + +// TestHelp checks that "cdd help" and "cdd --help" both exit 0 and print +// something to stdout. +func TestHelp(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "help command", args: []string{"help"}}, + {name: "long flag", args: []string{"--help"}}, + {name: "short flag", args: []string{"-h"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + code, stdout, stderr := runCLI(t, tt.args...) + if code != 0 { + t.Errorf("exit code = %d, want 0 (stderr: %q)", code, stderr) + } + if stdout == "" { + t.Error("stdout is empty, want help text") + } + }) } - return bin } diff --git a/internal/cli/init.go b/internal/cli/init.go new file mode 100644 index 0000000..dcb83b1 --- /dev/null +++ b/internal/cli/init.go @@ -0,0 +1,47 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/kryft-dev/cdd/internal/shell" +) + +// newInitCmd builds "cdd init ": exactly one argument in fish, +// bash, or zsh, else a usage error (exit 2) listing them. +func newInitCmd() *cobra.Command { + return &cobra.Command{ + Use: "init ", + Short: "print the Wrapper for fish, bash, or zsh", + Args: func(_ *cobra.Command, args []string) error { + if len(args) != 1 { + return newUsageError(fmt.Errorf("cdd init: expected exactly one shell argument: fish, bash, zsh")) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + script, err := shell.Script(args[0], passthrough(cmd.Root())) + if err != nil { + return newUsageError(err) + } + fmt.Fprint(cmd.OutOrStdout(), script) + return nil + }, + } +} + +// passthrough lists every command name in root's tree except "pick", the +// Wrapper's pass-through list. It is derived from the cobra command tree +// rather than hand maintained, so "help" and "completion" pass through +// without extra work. +func passthrough(root *cobra.Command) []string { + var names []string + for _, c := range root.Commands() { + if c.Name() == "pick" { + continue + } + names = append(names, c.Name()) + } + return names +} diff --git a/internal/cli/pick.go b/internal/cli/pick.go new file mode 100644 index 0000000..60d0a69 --- /dev/null +++ b/internal/cli/pick.go @@ -0,0 +1,69 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "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" +) + +// initHint is printed to stderr, after the chosen path, when stdout is a +// terminal and no Wrapper is forwarding the path into a Jump. +const initHint = `cdd: no Wrapper installed, so this path was only printed. +cdd: add "cdd init | source" (or the "eval" form) to your shell config so cdd can Jump.` + +// newPickCmd builds "cdd pick [query]". +func newPickCmd() *cobra.Command { + return &cobra.Command{ + Use: "pick [query]", + Short: "prints the chosen Project's path; the Wrapper turns it into a Jump", + Args: cobra.MaximumNArgs(1), + RunE: pickRunE, + } +} + +// pickRunE implements both "cdd pick [query]" and the bare "cdd" root +// command: load config, open History at its default path, resolve the +// query to a Project via jump.Resolve and picker.Run, and print the +// chosen absolute path plus a newline to stdout and nothing else. +// +// A cancelled Picker returns jump.ErrCancelled, which Run reports as exit +// 130 with no message. Any other error is reported by Run as exit 1 with +// the error's message on stderr. +func pickRunE(cmd *cobra.Command, args []string) error { + query := "" + if len(args) > 0 { + query = args[0] + } + + cfg, err := config.Load() + if err != nil { + return err + } + + histPath, err := history.DefaultPath() + if err != nil { + return fmt.Errorf("cdd: locate History: %w", err) + } + + hist, err := history.Open(histPath, cfg.History.MaxVisits) + if err != nil { + return err + } + + abs, err := jump.Resolve(cmd.Context(), cfg, hist, query, picker.Run) + if err != nil { + return err + } + + out := cmd.OutOrStdout() + fmt.Fprintln(out, abs) + if isTerminal(out) { + fmt.Fprintln(cmd.ErrOrStderr(), initHint) + } + return nil +} diff --git a/internal/cli/scan.go b/internal/cli/scan.go new file mode 100644 index 0000000..fab92e3 --- /dev/null +++ b/internal/cli/scan.go @@ -0,0 +1,45 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/kryft-dev/cdd/internal/config" + "github.com/kryft-dev/cdd/internal/history" + "github.com/kryft-dev/cdd/internal/scan" +) + +// newScanCmd builds "cdd scan": seed History with one Visit per discovered +// Project and print a one-line summary. +func newScanCmd() *cobra.Command { + return &cobra.Command{ + Use: "scan", + Short: "seed History with one Visit per discovered Project", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + + histPath, err := history.DefaultPath() + if err != nil { + return fmt.Errorf("cdd: locate History: %w", err) + } + + hist, err := history.Open(histPath, cfg.History.MaxVisits) + if err != nil { + return err + } + + summary, err := scan.Run(cmd.Context(), cfg, hist) + if err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Seeded %d Visits across %d Projects\n", summary.Seeded, summary.Projects) + return nil + }, + } +} diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..d5faaed --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,39 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// version, commit, and date are set at build time via -ldflags (see +// .goreleaser.yaml). They default to values that make an unflagged build +// identify itself as a dev build. +var ( + version = "dev" + commit = "" + date = "" +) + +// newVersionCmd builds "cdd version", which prints the same output as +// "cdd --version". +func newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "print the cdd version", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + fmt.Fprintln(cmd.OutOrStdout(), versionString()) + return nil + }, + } +} + +// versionString formats the version, commit, and date set by ldflags. When +// none of them were set, it reports "cdd dev (dev)". +func versionString() string { + if version == "dev" && commit == "" && date == "" { + return "cdd dev (dev)" + } + return fmt.Sprintf("cdd %s (%s %s)", version, commit, date) +}