summaryrefslogtreecommitdiff
path: root/internal/cli/cli_dispatch.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-18 08:39:04 +0300
committerPaul Buetow <paul@buetow.org>2026-04-18 08:39:04 +0300
commit7bca02bddcc88002e8edf72d2f20f526cf9d2668 (patch)
tree7bd907e787628393d52a3281ce751d2f807421ac /internal/cli/cli_dispatch.go
parent5874a7a5d3cac75b76bdf2f7793a05ce3c45b4d7 (diff)
refactor: split cli.go into focused files by responsibility
Extract cli.go into four dedicated files to improve separation of concerns and readability: cli_flags.go (flag parsing), cli_backend.go (backend init), cli_commands.go (command handlers), cli_dispatch.go (command routing). Fix inaccurate comments on logMsg/warn that claimed declaration order matters in Go; package-level functions are resolved across the whole file. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/cli/cli_dispatch.go')
-rw-r--r--internal/cli/cli_dispatch.go301
1 files changed, 301 insertions, 0 deletions
diff --git a/internal/cli/cli_dispatch.go b/internal/cli/cli_dispatch.go
new file mode 100644
index 0000000..ef23cd5
--- /dev/null
+++ b/internal/cli/cli_dispatch.go
@@ -0,0 +1,301 @@
+// Package cli — shell loop and command dispatcher.
+//
+// This file implements the interactive readline shell loop (shellLoop) and the
+// two-tier command dispatcher (dispatch → dispatchSimple | dispatchSearch).
+// It intentionally contains no flag parsing or backend construction; those
+// responsibilities live in cli_flags.go and cli_backend.go respectively.
+package cli
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+
+ "codeberg.org/snonux/foostore/internal/store"
+ "codeberg.org/snonux/foostore/internal/version"
+)
+
+// run dispatches a single command (when argv is non-empty and no shell flag is
+// set) or enters the interactive shell loop. Returns an exit code.
+func (c *CLI) run(ctx context.Context, argv []string) int {
+ // Enter shell mode when: no arguments, $FOOSTORE_SHELL is set, or the first
+ // argument is "shell". Mirrors the Ruby shell_loop entry conditions.
+ enterShell := len(argv) == 0 ||
+ os.Getenv("FOOSTORE_SHELL") != "" ||
+ (len(argv) > 0 && argv[0] == "shell")
+
+ if enterShell {
+ return c.shellLoop(ctx)
+ }
+
+ return c.dispatch(ctx, argv)
+}
+
+// shellLoop runs the interactive readline loop, reading commands until the
+// user presses Ctrl+D (EOF) or types "exit". Mirrors Ruby#shell_loop.
+// c.lastResult is updated by dispatch and accessible between iterations.
+func (c *CLI) shellLoop(ctx context.Context) int {
+ ec := 0
+ logMsg("Interactive mode (vi keys): Ctrl-] for normal mode, i for insert | Enter fuzzy picker | ctrl-t/y/o/e (or alt-t/y/o/e) in picker")
+
+ for {
+ line, err := c.sh.ReadLine(ctx)
+ if err == io.EOF {
+ break // Ctrl+D — clean exit.
+ }
+ if err != nil {
+ warn(fmt.Sprintf("readline error: %v", err))
+ continue
+ }
+
+ argv := strings.Fields(line)
+ if len(argv) == 0 {
+ ec = c.handleEmptyInput(ctx)
+ continue
+ }
+
+ // "last" and "exit" are intercepted before dispatch.
+ if argv[0] == "last" {
+ fmt.Println(c.lastResult)
+ continue
+ }
+ if argv[0] == "exit" {
+ logMsg("Good bye")
+ break
+ }
+
+ ec = c.dispatch(ctx, argv)
+ }
+
+ return ec
+}
+
+// handleEmptyInput handles an empty input line in the shell loop by running
+// the fzf picker. Updates c.lastResult when a selection is made and
+// dispatches the chosen picker action. Returns an exit code.
+func (c *CLI) handleEmptyInput(ctx context.Context) int {
+ result, fzfErr := c.st.FzfInteractive(ctx)
+ if fzfErr != nil {
+ warn(fzfErr.Error())
+ return 1
+ }
+ if result.Description != "" {
+ c.lastResult = result.Description
+ logMsg(fmt.Sprintf("Picked: %s", result.Description))
+ return c.dispatchPickerAction(ctx, result)
+ }
+ return 0
+}
+
+// pickerActionArgv maps a picker action to the argv slice for dispatch.
+// Returns nil for actions that do not map to a CLI command (e.g. PickerSelect).
+func pickerActionArgv(action store.PickerAction, description string) []string {
+ switch action {
+ case store.PickerCat:
+ return []string{"cat", description}
+ case store.PickerPaste:
+ return []string{"paste", description}
+ case store.PickerOpen:
+ return []string{"open", description}
+ case store.PickerEdit:
+ return []string{"edit", description}
+ default:
+ return nil
+ }
+}
+
+// dispatchPickerAction converts a PickerResult into an argv and dispatches it.
+// Returns 0 for picker actions that have no CLI command equivalent.
+func (c *CLI) dispatchPickerAction(ctx context.Context, result store.PickerResult) int {
+ argv := pickerActionArgv(result.Action, result.Description)
+ if len(argv) == 0 {
+ return 0
+ }
+ return c.dispatch(ctx, argv)
+}
+
+// dispatch routes a parsed argv slice to the appropriate handler.
+// It returns an exit code and updates c.lastResult when a non-empty result
+// is produced. The function delegates to dispatchSimple and dispatchSearch.
+func (c *CLI) dispatch(ctx context.Context, argv []string) int {
+ if len(argv) == 0 {
+ result, err := c.st.Fzf(ctx)
+ if err != nil {
+ warn(err.Error())
+ return 1
+ }
+ if result != "" {
+ c.lastResult = result
+ }
+ return 0
+ }
+
+ cmd := argv[0]
+
+ // Commands handled by dispatchSimple (no search term needed).
+ if ec, result, handled := c.dispatchSimple(ctx, argv, cmd); handled {
+ if result != "" {
+ c.lastResult = result
+ }
+ return ec
+ }
+
+ // Commands that require a search term (argv[1] or fallback to c.lastResult).
+ ec, result := c.dispatchSearch(ctx, argv, cmd)
+ if result != "" {
+ c.lastResult = result
+ }
+ return ec
+}
+
+// dispatchSimple handles commands that don't require a search term.
+// Returns (exitCode, lastResult, handled). handled=false means the command
+// is not in this set and should fall through to dispatchSearch.
+func (c *CLI) dispatchSimple(ctx context.Context, argv []string, cmd string) (int, string, bool) {
+ switch cmd {
+ case "ls":
+ indexes, err := c.st.Search(ctx, ".", store.ActionNone, nil, printIndex)
+ if err != nil {
+ warn(err.Error())
+ return 1, "", true
+ }
+ logMsg(fmt.Sprintf("%d entries", len(indexes)))
+ return 0, "", true
+ case "add":
+ return c.cmdAdd(ctx, argv), "", true
+ case "import":
+ return c.cmdImport(ctx, argv), "", true
+ case "import_r":
+ return c.cmdImportR(ctx, argv), "", true
+ case "sync":
+ return c.dispatchGitOp(func() error { return c.g.Sync(ctx, c.cfg.SyncRepos) })
+ case "status":
+ return c.dispatchGitOp(func() error { return c.g.Status(ctx) })
+ case "commit":
+ return c.dispatchGitOp(func() error { return c.g.Commit(ctx) })
+ case "reset":
+ return c.dispatchGitOp(func() error { return c.g.Reset(ctx) })
+ case "fullcommit":
+ return c.cmdFullCommit(ctx), "", true
+ case "shred":
+ return c.dispatchGitOp(func() error { return c.st.ShredAllExported(ctx) })
+ case "migrate-kdbx":
+ return c.cmdMigrateKDBX(ctx, argv), "", true
+ case "version":
+ logMsg(fmt.Sprintf("foostore %s", version.Version))
+ return 0, "", true
+ case "commands":
+ for _, name := range CommandList {
+ fmt.Println(name)
+ }
+ return 0, "", true
+ case "help":
+ printHelp()
+ return 0, "", true
+ case "shell":
+ // When typed in the shell loop, "shell" is intercepted by run() before
+ // dispatch is called, so this branch only fires in one-shot mode where
+ // switching to interactive mode is not meaningful.
+ logMsg("Use foostore without arguments to enter interactive mode")
+ return 0, "", true
+ case "exit":
+ logMsg("Good bye")
+ return 0, "", true
+ case "last":
+ // In shell mode "last" is intercepted by shellLoop; in one-shot mode
+ // there is no persistent lastResult, so we print the zero value.
+ fmt.Println(c.lastResult)
+ return 0, "", true
+ }
+
+ // Not a simple command — let dispatchSearch handle it.
+ return 0, "", false
+}
+
+// dispatchGitOp is a small adapter that wraps a no-argument git (or store)
+// operation into the (exitCode, lastResult, handled) triple expected by
+// dispatchSimple. Prints a warning and returns exit code 1 on error.
+// The context.Context parameter is omitted entirely: each op closure already
+// captures ctx from its call site in dispatchSimple, so no forwarding is needed.
+func (c *CLI) dispatchGitOp(op func() error) (int, string, bool) {
+ if err := op(); err != nil {
+ warn(err.Error())
+ return 1, "", true
+ }
+ return 0, "", true
+}
+
+// dispatchSearch handles commands that accept a search term: search, cat,
+// paste, get, export, pathexport, open, edit, rm, and the catch-all.
+// When no explicit term is supplied, c.lastResult is used as the fallback,
+// mirroring Ruby's `search_term = argv.length < 2 ? last_result : argv[1]`.
+func (c *CLI) dispatchSearch(ctx context.Context, argv []string, cmd string) (int, string) {
+ term := c.lastResult // fallback to last search result when no term given
+ if len(argv) > 1 {
+ term = argv[1]
+ }
+
+ switch cmd {
+ case "search":
+ return c.cmdSearchOnly(ctx, term)
+ case "get":
+ // "get" is an alias for "cat".
+ return c.cmdSearchAction(ctx, term, store.ActionCat, nil)
+ case "rm":
+ if err := c.st.Remove(ctx, term, os.Stdin); err != nil {
+ warn(err.Error())
+ return 1, ""
+ }
+ return 0, ""
+ case "cat", "paste", "export", "pathexport", "open", "edit":
+ action := SearchActions[cmd]
+ return c.cmdSearchAction(ctx, term, action, c.makeActionFn(ctx, action))
+ default:
+ // Unknown command: treat as a search term, mirroring Ruby's else branch.
+ // This allows bare search terms to be typed without prefixing "search".
+ return c.cmdSearchFallback(ctx, cmd)
+ }
+}
+
+// cmdSearchFallback treats an unrecognised command as a search term. This
+// mirrors the Ruby else branch in dispatch and allows users to type bare
+// search terms in the shell loop.
+func (c *CLI) cmdSearchFallback(ctx context.Context, term string) (int, string) {
+ indexes, err := c.st.Search(ctx, term, store.ActionNone, nil, printIndex)
+ if err != nil {
+ warn(err.Error())
+ return 1, ""
+ }
+ if len(indexes) > 0 {
+ return 0, indexes[0].Description
+ }
+ return 0, ""
+}
+
+// cmdSearchOnly runs a search without any action and returns the result.
+func (c *CLI) cmdSearchOnly(ctx context.Context, term string) (int, string) {
+ indexes, err := c.st.Search(ctx, term, store.ActionNone, nil, printIndex)
+ if err != nil {
+ warn(err.Error())
+ return 1, ""
+ }
+ if len(indexes) > 0 {
+ return 0, indexes[0].Description
+ }
+ return 0, ""
+}
+
+// cmdSearchAction runs a search with the given action and optional callback.
+func (c *CLI) cmdSearchAction(ctx context.Context, term string, action store.Action, actionFn func(context.Context, *store.Index, *store.Data) error) (int, string) {
+ indexes, err := c.st.Search(ctx, term, action, actionFn, printIndex)
+ if err != nil {
+ warn(err.Error())
+ return 1, ""
+ }
+ if len(indexes) > 0 {
+ return 0, indexes[0].Description
+ }
+ return 0, ""
+}