diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 60a9d6e..e363721 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -25,6 +25,12 @@ archives: - id: cdd formats: ["tar.gz"] name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + # Setting files replaces GoReleaser's default list, so name README and + # LICENSE too. + files: + - README.md + - LICENSE + - config.example.toml checksum: name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" diff --git a/README.md b/README.md index 3fe030e..2484aad 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,8 @@ the hints, leaving the count and messages. ### Actions An **Action** is a named command bound to a key and run on the selected -Project. Define one in `config.toml`: +Project. Define one in `config.toml` (`cdd config init` writes a starting +file with examples): ```toml [actions.code] @@ -285,7 +286,13 @@ directory as well as the name (see [Query syntax](#query-syntax)). `cdd` reads `config.toml` from `$XDG_CONFIG_HOME/cdd/config.toml`, falling back to `~/.config/cdd/config.toml` when `XDG_CONFIG_HOME` is unset. The -file is optional, and so is every key in it; these are the defaults: +file is optional, and so is every key in it. `cdd config init` writes a +commented example with every key and some Action recipes (it refuses to +replace an existing file unless you pass `--force`), `cdd config path` +prints where the file lives, and `cdd config edit` opens it in `$VISUAL`, +else `$EDITOR`, else `vi`, running `init` first if there is none. The same +file ships as `config.example.toml` in each release archive. These are the +defaults: ```toml exclude = [] diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..68f113b --- /dev/null +++ b/config.example.toml @@ -0,0 +1,88 @@ +# cdd config.toml, with every key and its default. +# +# Write this to the config path with `cdd config init`, and open it with +# `cdd config edit`. The file is optional, and so is every key in it. + +# Glob patterns (filepath.Match semantics) for directories `cdd scan` skips, +# along with everything below them. A pattern containing "/" is matched +# against the directory's absolute path, any other against its name alone, +# as in .gitignore. A leading "~" is expanded to your home directory; "$VAR" +# is left as-is. +# exclude = ["node_modules", "~/go/pkg/*"] +exclude = [] + +# When false, `cdd scan` skips hidden directories. +include_hidden = false + +[history] +# The maximum number of Visits kept in History. Must be at least 1. +max_visits = 1000 + +[keys] +# When true, the Picker opens with the list focused and uses vim-style keys: +# j/k move, f focuses the filter, esc returns to the list, ? opens a help +# overlay. +vim = false + +[picker] +# Which layout the Picker draws. "list", a flat fzf-style list, is the only +# one for now. Any other value is a config error. +layout = "list" + +# When true, the last line lists the keys of your Actions. false hides them, +# leaving the match count and messages. +hints = true + +# Actions +# +# An Action is a named command bound to a key and run on the selected +# Project. Built-in Actions exist unless you override them by name: +# +# jump enter Jump to the Project +# files ctrl+o open the Project in the file manager (detached) +# editor ctrl+e open the Project in $VISUAL, then $EDITOR, then vi +# remote ctrl+g open the git remote's home page (detached) +# copy ctrl+y copy the Project's path to the clipboard +# forget ctrl+d forget the Project, after a y/n confirm +# +# Each [actions.] table takes: +# key the Picker key that runs it ("ctrl+x", "alt+x", "f1", ...), or "" +# to unbind it. A binding displaces a built-in on the same key. +# esc and ctrl+c can never be bound. A plain printable key (a, ?) +# is an error unless keys.vim = true. +# run a shell command, handed to `sh -c` with the Project as the working +# directory and $CDD_PATH set to its path. {path} stands for the +# shell-quoted absolute path, {remote} for the shell-quoted home +# page URL of the git remote. Use {path}, not $path, which zsh ties +# to $PATH. +# jump when true, Jump to the Project once run exits successfully. +# detach when true, start run in its own session without waiting and keep +# the Picker open. Otherwise the Picker quits and run gets your +# terminal, so a TUI works. + +# Open the Project in VS Code, without leaving the Picker. +# [actions.code] +# key = "ctrl+v" +# run = "code {path}" +# detach = true + +# Run a TUI in the Project; it runs in the Project's directory, so it needs +# no {path}. +# [actions.lazygit] +# key = "ctrl+l" +# run = "lazygit" + +# Remap enter: open the editor on enter and move the Jump to ctrl+t. The +# editor's binding on enter displaces the Jump's, so move the Jump first. +# [actions.jump] +# key = "ctrl+t" +# [actions.editor] +# key = "enter" + +# Override a built-in's key: copy on ctrl+x instead of ctrl+y. +# [actions.copy] +# key = "ctrl+x" + +# Unbind a built-in, so a stray key press cannot forget a Project. +# [actions.forget] +# key = "" diff --git a/config_example.go b/config_example.go new file mode 100644 index 0000000..31b1769 --- /dev/null +++ b/config_example.go @@ -0,0 +1,12 @@ +// Package cdd holds config.example.toml, the commented config the release +// ships and `cdd config init` writes. The file lives at the repository +// root, where a release tarball picks it up, and //go:embed cannot reach +// above a package's own directory, so this root package embeds it. +package cdd + +import _ "embed" + +// ConfigExample is config.example.toml. +// +//go:embed config.example.toml +var ConfigExample []byte diff --git a/config_example_test.go b/config_example_test.go new file mode 100644 index 0000000..021aa83 --- /dev/null +++ b/config_example_test.go @@ -0,0 +1,48 @@ +package cdd_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kryft-dev/cdd" + "github.com/kryft-dev/cdd/internal/config" +) + +// TestConfigExampleLoads checks the embedded example is a valid config, as +// shipped and with every commented example uncommented. +func TestConfigExampleLoads(t *testing.T) { + t.Run("as shipped", func(t *testing.T) { + assertLoads(t, string(cdd.ConfigExample)) + }) + + // The Action examples are commented out, so uncomment every one: they + // must also load, all together. + t.Run("actions uncommented", func(t *testing.T) { + _, actions, found := strings.Cut(string(cdd.ConfigExample), "# [actions.code]") + if !found { + t.Fatal("no [actions.code] example found") + } + var b strings.Builder + b.WriteString("[actions.code]\n") + for _, line := range strings.Split(actions, "\n") { + if rest, ok := strings.CutPrefix(line, "# "); ok && strings.ContainsAny(rest, "=[") { + b.WriteString(rest + "\n") + } + } + assertLoads(t, b.String()) + }) +} + +func assertLoads(t *testing.T, body string) { + t.Helper() + + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + if _, err := config.LoadFrom(path); err != nil { + t.Fatalf("LoadFrom: %v\n%s", err, body) + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 67eeaa8..c292583 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1,6 +1,6 @@ // Package cli wires cdd's command surface with github.com/spf13/cobra: the // root command (bare behaves as pick), pick, init, scan, add, forget, -// version, plus +// config, version, plus // cobra's own help and completion commands. package cli @@ -58,8 +58,8 @@ func Run(args []string, stdout, stderr io.Writer) int { } // newRootCmd builds the cdd command tree: root (bare behaves as pick), -// pick, init, scan, add, forget, version, plus cobra's help and completion -// commands. +// pick, init, scan, add, forget, config, version, plus cobra's help and +// completion commands. func newRootCmd() *cobra.Command { root := &cobra.Command{ Use: "cdd", @@ -75,7 +75,7 @@ func newRootCmd() *cobra.Command { return pickRunE(cmd, nil) } - root.AddCommand(newPickCmd(), newInitCmd(), newScanCmd(), newAddCmd(), newForgetCmd(), newVersionCmd()) + root.AddCommand(newPickCmd(), newInitCmd(), newScanCmd(), newAddCmd(), newForgetCmd(), newConfigCmd(), newVersionCmd()) // Initialized here, not left to Execute, so the command tree is // complete (including "help" and "completion") whenever init's diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 532aea9..603e846 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -111,7 +111,7 @@ func TestInitPassthrough(t *testing.T) { t.Fatalf("exit code = %d, want 0 (stderr: %q)", code, stderr) } - for _, name := range []string{"init", "scan", "version", "help", "completion"} { + for _, name := range []string{"init", "scan", "config", "version", "help", "completion"} { if !strings.Contains(stdout, name) { t.Errorf("script does not mention pass-through command %q:\n%s", name, stdout) } diff --git a/internal/cli/config.go b/internal/cli/config.go new file mode 100644 index 0000000..47bcfde --- /dev/null +++ b/internal/cli/config.go @@ -0,0 +1,129 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/kryft-dev/cdd" + "github.com/kryft-dev/cdd/internal/config" +) + +// newConfigCmd builds "cdd config": init, path and edit, which write, locate +// and open config.toml. +func newConfigCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "create, locate, or edit config.toml", + } + cmd.AddCommand(newConfigInitCmd(), newConfigPathCmd(), newConfigEditCmd()) + return cmd +} + +// newConfigInitCmd builds "cdd config init [--force]": write the commented +// example config to the config path, creating its directories. It refuses +// to replace a file that exists unless --force is given. +func newConfigInitCmd() *cobra.Command { + var force bool + cmd := &cobra.Command{ + Use: "init", + Short: "write the commented example config.toml (--force replaces an existing one)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + path, err := config.Path() + if err != nil { + return err + } + if err := writeExample(path, force); err != nil { + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Wrote %s\n", path) + return err + }, + } + cmd.Flags().BoolVar(&force, "force", false, "replace config.toml if it exists") + return cmd +} + +// newConfigPathCmd builds "cdd config path": print where config.toml lives, +// whether or not it exists. +func newConfigPathCmd() *cobra.Command { + return &cobra.Command{ + Use: "path", + Short: "print the config.toml path", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + path, err := config.Path() + if err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), path) + return err + }, + } +} + +// newConfigEditCmd builds "cdd config edit": open config.toml in $VISUAL, +// else $EDITOR, else vi, writing the example first when there is no file. +func newConfigEditCmd() *cobra.Command { + return &cobra.Command{ + Use: "edit", + Short: "open config.toml in $VISUAL or $EDITOR, creating it first if missing", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + path, err := config.Path() + if err != nil { + return err + } + switch err := writeExample(path, false); { + case err == nil: + if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Wrote %s\n", path); err != nil { + return err + } + case !errors.Is(err, os.ErrExist): + return err + } + + // The shell resolves the editor, so a value such as "code -w" + // works, as it does for the built-in editor Action. + editor := exec.Command("sh", "-c", `${VISUAL:-${EDITOR:-vi}} "$1"`, "sh", path) + editor.Stdin, editor.Stdout, editor.Stderr = os.Stdin, cmd.OutOrStdout(), cmd.ErrOrStderr() + if err := editor.Run(); err != nil { + return fmt.Errorf("cdd config edit: %w", err) + } + return nil + }, + } +} + +// writeExample writes the example config to path, creating its directories. +// Unless force is set it fails with an error wrapping os.ErrExist when path +// exists, and never overwrites it. +func writeExample(path string, force bool) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("cdd config: %w", err) + } + flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC + if !force { + flags = os.O_WRONLY | os.O_CREATE | os.O_EXCL + } + f, err := os.OpenFile(path, flags, 0o644) + if err != nil { + if errors.Is(err, os.ErrExist) { + return fmt.Errorf("cdd config: %s already exists (use --force to replace it): %w", path, os.ErrExist) + } + return fmt.Errorf("cdd config: %w", err) + } + if _, err := f.Write(cdd.ConfigExample); err != nil { + _ = f.Close() + return fmt.Errorf("cdd config: write %s: %w", path, err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("cdd config: write %s: %w", path, err) + } + return nil +} diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go new file mode 100644 index 0000000..ae77e39 --- /dev/null +++ b/internal/cli/config_test.go @@ -0,0 +1,147 @@ +package cli_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kryft-dev/cdd" +) + +// isolateConfig points XDG_CONFIG_HOME at a fresh temp directory and +// returns the config path under it, which does not exist yet. +func isolateConfig(t *testing.T) string { + t.Helper() + + xdg := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdg) + return filepath.Join(xdg, "cdd", "config.toml") +} + +// readFile returns path's contents. +func readFile(t *testing.T, path string) string { + t.Helper() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + return string(data) +} + +// TestConfigPath checks "cdd config path" prints the config path, whether +// or not the file exists. +func TestConfigPath(t *testing.T) { + want := isolateConfig(t) + + code, stdout, stderr := runCLI(t, "config", "path") + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %q)", code, stderr) + } + if got := strings.TrimSpace(stdout); got != want { + t.Errorf("stdout = %q, want %q", got, want) + } +} + +// TestConfigInit checks "cdd config init" writes the example and creates +// the directories, refuses to replace an existing file, and replaces it +// with --force. +func TestConfigInit(t *testing.T) { + path := isolateConfig(t) + + code, stdout, stderr := runCLI(t, "config", "init") + if code != 0 { + t.Fatalf("init: exit code = %d, want 0 (stderr: %q)", code, stderr) + } + if !strings.Contains(stdout, path) { + t.Errorf("init: stdout = %q, want it to name %q", stdout, path) + } + if got := readFile(t, path); got != string(cdd.ConfigExample) { + t.Errorf("init wrote %q, want the example config", got) + } + + if err := os.WriteFile(path, []byte("include_hidden = true\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + code, _, stderr = runCLI(t, "config", "init") + if code != 1 { + t.Errorf("init over a file: exit code = %d, want 1", code) + } + if !strings.Contains(stderr, "--force") { + t.Errorf("init over a file: stderr = %q, want it to mention --force", stderr) + } + if got := readFile(t, path); got != "include_hidden = true\n" { + t.Errorf("init over a file changed it to %q", got) + } + + code, _, stderr = runCLI(t, "config", "init", "--force") + if code != 0 { + t.Fatalf("init --force: exit code = %d, want 0 (stderr: %q)", code, stderr) + } + if got := readFile(t, path); got != string(cdd.ConfigExample) { + t.Errorf("init --force wrote %q, want the example config", got) + } +} + +// fakeEditor writes an editor script that appends its argument to a log +// beside it, and returns the script and log paths. +func fakeEditor(t *testing.T) (script, log string) { + t.Helper() + + dir := t.TempDir() + log = filepath.Join(dir, "log") + script = filepath.Join(dir, "editor") + body := "#!/bin/sh\necho \"$0 $*\" >> " + log + "\n" + if err := os.WriteFile(script, []byte(body), 0o755); err != nil { + t.Fatalf("WriteFile: %v", err) + } + return script, log +} + +// TestConfigEdit checks "cdd config edit" runs $VISUAL, else $EDITOR, on +// the config path, writing the example first when the file is missing and +// leaving an existing file as it is. +func TestConfigEdit(t *testing.T) { + path := isolateConfig(t) + script, log := fakeEditor(t) + + t.Setenv("VISUAL", script+" -w") + t.Setenv("EDITOR", "false") + + if code, _, stderr := runCLI(t, "config", "edit"); code != 0 { + t.Fatalf("edit: exit code = %d, want 0 (stderr: %q)", code, stderr) + } + if got := readFile(t, path); got != string(cdd.ConfigExample) { + t.Errorf("edit on a missing file wrote %q, want the example config", got) + } + if got, want := readFile(t, log), script+" -w "+path+"\n"; got != want { + t.Errorf("$VISUAL ran %q, want %q", got, want) + } + + if err := os.WriteFile(path, []byte("include_hidden = true\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", script) + if code, _, stderr := runCLI(t, "config", "edit"); code != 0 { + t.Fatalf("edit: exit code = %d, want 0 (stderr: %q)", code, stderr) + } + if got := readFile(t, path); got != "include_hidden = true\n" { + t.Errorf("edit on an existing file changed it to %q", got) + } + if got := readFile(t, log); !strings.HasSuffix(got, script+" "+path+"\n") { + t.Errorf("$EDITOR did not run on %s: log %q", path, got) + } +} + +// TestConfigEditFailure checks a failing editor makes "cdd config edit" +// exit 1. +func TestConfigEditFailure(t *testing.T) { + isolateConfig(t) + t.Setenv("VISUAL", "false") + + if code, _, _ := runCLI(t, "config", "edit"); code != 1 { + t.Errorf("exit code = %d, want 1", code) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 896d824..17cb388 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -92,18 +92,28 @@ func defaultConfig() Config { } } -// Load reads cdd's configuration from $XDG_CONFIG_HOME/cdd/config.toml, -// defaulting to ~/.config/cdd/config.toml when XDG_CONFIG_HOME is unset. -func Load() (Config, error) { +// Path returns where cdd's configuration lives: +// $XDG_CONFIG_HOME/cdd/config.toml, defaulting to ~/.config/cdd/config.toml +// when XDG_CONFIG_HOME is unset. +func Path() (string, error) { dir := os.Getenv("XDG_CONFIG_HOME") if dir == "" { home, err := os.UserHomeDir() if err != nil { - return Config{}, fmt.Errorf("config: determine home directory: %w", err) + return "", fmt.Errorf("config: determine home directory: %w", err) } dir = filepath.Join(home, ".config") } - return LoadFrom(filepath.Join(dir, "cdd", "config.toml")) + return filepath.Join(dir, "cdd", "config.toml"), nil +} + +// Load reads cdd's configuration from Path. +func Load() (Config, error) { + path, err := Path() + if err != nil { + return Config{}, err + } + return LoadFrom(path) } // LoadFrom reads cdd's configuration from path. A missing file is every diff --git a/internal/config/path_test.go b/internal/config/path_test.go new file mode 100644 index 0000000..cd8f1bd --- /dev/null +++ b/internal/config/path_test.go @@ -0,0 +1,39 @@ +package config_test + +import ( + "path/filepath" + "testing" + + "github.com/kryft-dev/cdd/internal/config" +) + +// TestPath checks Path honours XDG_CONFIG_HOME and falls back to +// ~/.config. +func TestPath(t *testing.T) { + t.Run("xdg", func(t *testing.T) { + xdg := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdg) + + got, err := config.Path() + if err != nil { + t.Fatalf("Path: %v", err) + } + if want := filepath.Join(xdg, "cdd", "config.toml"); got != want { + t.Errorf("Path = %q, want %q", got, want) + } + }) + + t.Run("home fallback", func(t *testing.T) { + home := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("HOME", home) + + got, err := config.Path() + if err != nil { + t.Fatalf("Path: %v", err) + } + if want := filepath.Join(home, ".config", "cdd", "config.toml"); got != want { + t.Errorf("Path = %q, want %q", got, want) + } + }) +}