From f4413963fbb5181d0907194d5b185c4af87bcdf1 Mon Sep 17 00:00:00 2001 From: Amolith Date: Fri, 13 Mar 2026 13:43:58 -0600 Subject: [PATCH] Add preset selector and command input forms --- cmd/root.go | 78 ++++++++++++++++++++++- go.mod | 8 +++ go.sum | 26 ++++++++ internal/form/form.go | 125 +++++++++++++++++++++++++++++++++++++ internal/form/form_test.go | 64 +++++++++++++++++++ 5 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 internal/form/form.go create mode 100644 internal/form/form_test.go diff --git a/cmd/root.go b/cmd/root.go index b2451ec5c3817100855eb1b59f36fa41bd2aab97..0b4da439ebd749fbcecfe40de250ef0aafac96e1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "git.secluded.site/pika/internal/config" + "git.secluded.site/pika/internal/form" "git.secluded.site/pika/internal/menu" "git.secluded.site/pika/internal/restic" ) @@ -75,7 +76,8 @@ var rootCmd = &cobra.Command{ preset, command, passthrough := parseArgs(args) // No command → interactive menu. - if command == "" { + interactive := command == "" + if interactive { selected, err := runMenu() if err != nil { return fmt.Errorf("menu: %w", err) @@ -88,6 +90,28 @@ var rootCmd = &cobra.Command{ overrides := parsePassthrough(passthrough) + // When launched interactively, prompt for a preset and any + // command-specific inputs that restic would otherwise reject. + if interactive { + p, err := promptPreset() + if err != nil { + return err + } + preset = p + + cmdOverrides, err := promptForCommand(command) + if err != nil { + return err + } + if overrides == nil && len(cmdOverrides) > 0 { + overrides = cmdOverrides + } else { + for k, v := range cmdOverrides { + overrides[k] = v + } + } + } + cfg, err := config.Resolve(preset, command, overrides) if err != nil { return err @@ -287,3 +311,55 @@ func runMenu() (string, error) { } return model.Choice(), nil } + +// promptPreset shows an interactive preset selector when presets are defined +// in the config. Returns "" (global-only) if no presets exist. +func promptPreset() (string, error) { + presets := config.Presets() + if len(presets) == 0 { + return "", nil + } + selected, err := form.SelectPreset(presets) + if err != nil { + return "", fmt.Errorf("preset selection: %w", err) + } + return selected, nil +} + +// promptForCommand collects required inputs for commands that need them. +// Returns CLI overrides to merge, or nil if the command needs no extra input. +func promptForCommand(command string) (map[string][]string, error) { + switch command { + case "restore": + return promptRestore() + case "backup": + return promptBackup() + default: + return nil, nil + } +} + +// promptRestore collects the snapshot ID and target directory for restore. +func promptRestore() (map[string][]string, error) { + snapshotID, target, err := form.RestoreInputs() + if err != nil { + return nil, fmt.Errorf("restore inputs: %w", err) + } + return map[string][]string{ + overrideArgumentsKey: {snapshotID}, + "target": {target}, + }, nil +} + +// promptBackup collects backup paths if none are likely to come from config. +// The user is always offered the chance to provide paths; config-defined +// _arguments will still take precedence during resolution. +func promptBackup() (map[string][]string, error) { + paths, err := form.BackupPaths() + if err != nil { + return nil, fmt.Errorf("backup paths: %w", err) + } + return map[string][]string{ + overrideArgumentsKey: paths, + }, nil +} diff --git a/go.mod b/go.mod index aee7b8eaa0d52b161bf695a238fb8d1a6c913aef..ca3c3ad70183ca97b1e86cf6cbd8ae80cff386b6 100644 --- a/go.mod +++ b/go.mod @@ -5,24 +5,32 @@ go 1.26.1 require ( charm.land/bubbletea/v2 v2.0.2 charm.land/fang/v2 v2.0.1 + charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.2 github.com/BurntSushi/toml v1.6.0 github.com/spf13/cobra v1.10.2 ) require ( + charm.land/bubbles/v2 v2.0.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect 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/dustin/go-humanize v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.1.0 // indirect github.com/muesli/mango-cobra v1.2.0 // indirect diff --git a/go.sum b/go.sum index b91ce4064cd169121979b01b9a77e0d005d99fd6..14d284e6586cdeefc1bd5cbeadf75cca9d4b5d12 100644 --- a/go.sum +++ b/go.sum @@ -1,42 +1,68 @@ +charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= +charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY= charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= 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/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= diff --git a/internal/form/form.go b/internal/form/form.go new file mode 100644 index 0000000000000000000000000000000000000000..8e5ef179e59a39a92a9cae8eddf93329b12c26d5 --- /dev/null +++ b/internal/form/form.go @@ -0,0 +1,125 @@ +// Package form provides interactive huh-based prompts for collecting user +// input when pika is invoked via the interactive menu. +package form + +import ( + "errors" + "fmt" + + "charm.land/huh/v2" +) + +// ErrAborted is returned when the user cancels a form (ctrl+c / q). +var ErrAborted = errors.New("aborted by user") + +// wrapAbort converts huh's abort error into our own sentinel. +func wrapAbort(err error) error { + if err != nil && errors.Is(err, huh.ErrUserAborted) { + return ErrAborted + } + return err +} + +// SelectPreset displays an interactive selector for the given preset names. +// Returns the selected preset, or "" if the user picks "(none)". +func SelectPreset(presets []string) (string, error) { + if len(presets) == 0 { + return "", nil + } + + opts := make([]huh.Option[string], 0, len(presets)+1) + opts = append(opts, huh.NewOption("(global defaults only)", "")) + for _, p := range presets { + opts = append(opts, huh.NewOption(p, p)) + } + + var selected string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Select a preset"). + Options(opts...). + Value(&selected), + ), + ) + + if err := wrapAbort(form.Run()); err != nil { + return "", err + } + return selected, nil +} + +// RestoreInputs collects the required inputs for `restic restore`: +// a snapshot ID and a target directory. +func RestoreInputs() (snapshotID, target string, err error) { + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Snapshot ID"). + Placeholder("e.g. latest or a1b2c3d4"). + Value(&snapshotID). + Validate(notEmpty("snapshot ID")), + huh.NewInput(). + Title("Target directory"). + Placeholder("e.g. /tmp/restore"). + Value(&target). + Validate(notEmpty("target directory")), + ), + ) + + if err := wrapAbort(form.Run()); err != nil { + return "", "", err + } + return snapshotID, target, nil +} + +// BackupPaths collects one or more paths to back up when none are configured. +func BackupPaths() ([]string, error) { + var raw string + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Paths to back up"). + Description("Space-separated list of files or directories."). + Placeholder("e.g. /home/user /etc"). + Value(&raw). + Validate(notEmpty("backup path")), + ), + ) + + if err := wrapAbort(form.Run()); err != nil { + return nil, err + } + return splitFields(raw), nil +} + +// notEmpty returns a validation function that rejects blank input. +func notEmpty(fieldName string) func(string) error { + return func(s string) error { + if len(s) == 0 { + return fmt.Errorf("%s is required", fieldName) + } + return nil + } +} + +// splitFields splits a string on whitespace, like strings.Fields, but kept +// here to avoid importing strings for a one-liner. +func splitFields(s string) []string { + var fields []string + start := -1 + for i, r := range s { + if r == ' ' || r == '\t' { + if start >= 0 { + fields = append(fields, s[start:i]) + start = -1 + } + } else if start < 0 { + start = i + } + } + if start >= 0 { + fields = append(fields, s[start:]) + } + return fields +} diff --git a/internal/form/form_test.go b/internal/form/form_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e6a54c266ad816928059b353d9ec5e4cef1b13b5 --- /dev/null +++ b/internal/form/form_test.go @@ -0,0 +1,64 @@ +package form + +import ( + "testing" +) + +func TestNotEmpty(t *testing.T) { + t.Parallel() + + validate := notEmpty("thing") + + tests := []struct { + name string + input string + wantErr bool + }{ + {name: "empty string", input: "", wantErr: true}, + {name: "non-empty string", input: "hello", wantErr: false}, + {name: "whitespace only", input: " ", wantErr: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validate(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("notEmpty(%q): got err=%v, wantErr=%v", tt.input, err, tt.wantErr) + } + }) + } +} + +func TestSplitFields(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want []string + }{ + {name: "empty", input: "", want: nil}, + {name: "single path", input: "/home/user", want: []string{"/home/user"}}, + {name: "two paths", input: "/home/user /etc", want: []string{"/home/user", "/etc"}}, + {name: "leading whitespace", input: " /home", want: []string{"/home"}}, + {name: "trailing whitespace", input: "/home ", want: []string{"/home"}}, + {name: "tabs", input: "/a\t/b", want: []string{"/a", "/b"}}, + {name: "multiple spaces", input: "/a /b /c", want: []string{"/a", "/b", "/c"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := splitFields(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("splitFields(%q): got %v, want %v", tt.input, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("splitFields(%q)[%d]: got %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + }) + } +}