diff --git a/cmd/root.go b/cmd/root.go index b3dd8b8751227270ceff034eda526be8f01248e7..d22692bd953cb5a63e2fb4054ab6a43c69016e89 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "os" "slices" @@ -9,6 +10,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/fang/v2" + "charm.land/huh/v2/spinner" "github.com/spf13/cobra" "git.secluded.site/keld/internal/config" @@ -265,29 +267,108 @@ func promptForCommand(command string, cfg *config.ResolvedConfig) (map[string][] } } -// promptRestore collects the snapshot ID and target directory for restore, -// skipping prompts for values already present in the resolved config. +// promptRestore collects the snapshot ID, target directory, and overwrite +// behavior for restore, skipping prompts for values already present in +// the resolved config. +// +// Snapshot selection uses an interactive picker when the resolved config +// has a repository configured; otherwise falls back to a text input. +// Auth/network errors during snapshot listing produce a terse note and +// fall back to text input. User cancellation aborts entirely. func promptRestore(cfg *config.ResolvedConfig) (map[string][]string, error) { - hasSnapshotID := len(cfg.Arguments) > 0 - hasTarget := cfg.HasFlag("target") + overrides := make(map[string][]string) + + // Step 1: Snapshot ID. + if len(cfg.Arguments) == 0 { + snapshotID, err := promptSnapshotID(cfg) + if err != nil { + return nil, err + } + if snapshotID == "" { + return nil, fmt.Errorf("snapshot ID is required") + } + overrides[overrideArgumentsKey] = []string{snapshotID} + } + + // Step 2: Target directory. + if !cfg.HasFlag("target") { + target, err := form.TargetDirectory() + if err != nil { + return nil, fmt.Errorf("target directory: %w", err) + } + overrides["target"] = []string{target} + } - if hasSnapshotID && hasTarget { + // Step 3: Overwrite behavior. + if !cfg.HasFlag("overwrite") { + overwrite, err := form.SelectOverwrite() + if err != nil { + return nil, fmt.Errorf("overwrite selection: %w", err) + } + overrides["overwrite"] = []string{overwrite} + } + + if len(overrides) == 0 { return nil, nil } + return overrides, nil +} - snapshotID, target, err := form.RestoreInputs(hasSnapshotID, hasTarget) +// promptSnapshotID attempts to show an interactive snapshot picker using +// the repository from the resolved config. Falls back to a manual text +// input when: +// - No repository is configured (silent fallback) +// - Snapshot listing fails (prints a terse note, then text input) +// - No snapshots exist (prints a note, then text input) +// +// If the user picks "Enter ID manually…" from the picker, switches to +// the text input. User cancellation (ctrl+c) always aborts entirely. +func promptSnapshotID(cfg *config.ResolvedConfig) (string, error) { + var snapshots []restic.Snapshot + var listErr error + + err := spinner.New(). + Title("Loading snapshots…"). + Action(func() { + snapshots, listErr = restic.ListSnapshots(cfg) + }). + Run() if err != nil { - return nil, fmt.Errorf("restore inputs: %w", err) + // Spinner itself failed (unlikely); fall back gracefully. + return form.ManualSnapshotID() } - overrides := make(map[string][]string) - if !hasSnapshotID { - overrides[overrideArgumentsKey] = []string{snapshotID} + if listErr != nil { + // Silent fallback for "no repo configured"; terse note for + // anything else (auth failure, network error, bad JSON, etc.) + if !isNoRepoError(listErr) { + fmt.Fprintf(os.Stderr, "Could not list snapshots: %v\n", listErr) + } + return form.ManualSnapshotID() } - if !hasTarget { - overrides["target"] = []string{target} + + if len(snapshots) == 0 { + fmt.Fprintln(os.Stderr, "No snapshots found in repository.") + return form.ManualSnapshotID() } - return overrides, nil + + selected, err := form.SelectSnapshot(snapshots) + if err != nil { + return "", err + } + + if form.IsManualEntry(selected) { + return form.ManualSnapshotID() + } + + return selected, nil +} + +// isNoRepoError checks whether the error from ListSnapshots indicates +// that no repository was configured (as opposed to an auth/network/etc. +// failure). +func isNoRepoError(err error) bool { + return errors.Is(err, restic.ErrNoRepo) } // promptBackup collects backup paths when none are configured in the preset. diff --git a/internal/form/form.go b/internal/form/form.go index 5e13b2ff6a9ff9fad37c32813f23a571e2f47929..64d4c1e33b641a099d407405451fc63f9b27a82c 100644 --- a/internal/form/form.go +++ b/internal/form/form.go @@ -49,34 +49,23 @@ func SelectPreset(presets []string) (string, error) { return selected, nil } -// RestoreInputs collects the required inputs for `restic restore`: -// a snapshot ID and a target directory. Fields whose corresponding -// "has" parameter is true are skipped (already provided by config). -func RestoreInputs(hasSnapshotID, hasTarget bool) (snapshotID, target string, err error) { - var fields []huh.Field - if !hasSnapshotID { - fields = append(fields, huh.NewInput(). - Title("Snapshot ID"). - Placeholder("e.g. latest or a1b2c3d4"). - Value(&snapshotID). - Validate(notEmpty("snapshot ID"))) - } - if !hasTarget { - fields = append(fields, huh.NewInput(). - Title("Target directory"). - Placeholder("e.g. /tmp/restore"). - Value(&target). - Validate(notEmpty("target directory"))) - } - if len(fields) == 0 { - return "", "", nil - } +// TargetDirectory prompts for a restore target directory. +func TargetDirectory() (string, error) { + var target string + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Target directory"). + Placeholder("e.g. /tmp/restore"). + Value(&target). + Validate(notEmpty("target directory")), + ), + ) - form := huh.NewForm(huh.NewGroup(fields...)) if err := wrapAbort(form.Run()); err != nil { - return "", "", err + return "", err } - return snapshotID, target, nil + return target, nil } // BackupPaths collects one or more paths to back up when none are configured. diff --git a/internal/form/snapshots.go b/internal/form/snapshots.go new file mode 100644 index 0000000000000000000000000000000000000000..7ceade41abd70135130b27d9e1a1be18ed406465 --- /dev/null +++ b/internal/form/snapshots.go @@ -0,0 +1,104 @@ +package form + +import ( + "charm.land/huh/v2" + + "git.secluded.site/keld/internal/restic" +) + +// manualEntryValue is the sentinel returned by SelectSnapshot when the +// user chooses to type a snapshot ID manually instead of picking from +// the list. +const manualEntryValue = "__manual__" + +// IsManualEntry reports whether the value returned by SelectSnapshot +// indicates the user chose manual entry. +func IsManualEntry(v string) bool { + return v == manualEntryValue +} + +// SelectSnapshot presents an interactive picker for the given snapshots. +// Each snapshot is shown as a formatted summary line; the selected +// snapshot's short ID is returned. +// +// An "Enter ID manually…" option is always included at the end so users +// can type arbitrary IDs (including the snapshotID:subfolder syntax). +// +// Returns the selected short ID, or the manualEntryValue sentinel +// (check with IsManualEntry), or ErrAborted if the user cancels. +func SelectSnapshot(snapshots []restic.Snapshot) (string, error) { + opts := make([]huh.Option[string], 0, len(snapshots)+1) + for _, s := range snapshots { + opts = append(opts, huh.NewOption(restic.FormatSnapshotLine(s), s.ShortID)) + } + opts = append(opts, huh.NewOption("Enter ID manually…", manualEntryValue)) + + var selected string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Select a snapshot"). + Options(opts...). + Filtering(true). + Value(&selected), + ), + ) + + if err := wrapAbort(form.Run()); err != nil { + return "", err + } + return selected, nil +} + +// ManualSnapshotID prompts the user to type a snapshot ID. This is used +// as the fallback when snapshot listing fails or when the user chooses +// "Enter ID manually…" from the picker. +func ManualSnapshotID() (string, error) { + var id string + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Snapshot ID"). + Description("Supports snapshotID:subfolder syntax, or \"latest\"."). + Placeholder("e.g. latest or a1b2c3d4 or a1b2c3d4:/home/user"). + Value(&id). + Validate(notEmpty("snapshot ID")), + ), + ) + + if err := wrapAbort(form.Run()); err != nil { + return "", err + } + return id, nil +} + +// overwriteOptions defines the choices for --overwrite in the order +// they're presented to the user. +var overwriteOptions = []huh.Option[string]{ + huh.NewOption("if-changed (recommended — only restore what differs)", "if-changed"), + huh.NewOption("if-newer (only overwrite older files)", "if-newer"), + huh.NewOption("never (skip existing files entirely)", "never"), + huh.NewOption("always (restic default — overwrite everything)", "always"), +} + +// SelectOverwrite presents an interactive picker for the --overwrite +// behavior. The cursor starts on "if-changed". +// +// Returns the selected value (one of: if-changed, if-newer, never, +// always), or ErrAborted if the user cancels. +func SelectOverwrite() (string, error) { + var selected string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Overwrite existing files?"). + Options(overwriteOptions...). + Value(&selected), + ), + ) + + if err := wrapAbort(form.Run()); err != nil { + return "", err + } + return selected, nil +} diff --git a/internal/restic/list_snapshots.go b/internal/restic/list_snapshots.go index 355e5f876ea707c78e95697162d703fc85f11157..f3a927b97b43fba90f0a4d38bb5d8eced4e4bfdf 100644 --- a/internal/restic/list_snapshots.go +++ b/internal/restic/list_snapshots.go @@ -2,14 +2,20 @@ package restic import ( "bytes" + "errors" "fmt" "maps" + "os" "os/exec" "sort" "git.secluded.site/keld/internal/config" ) +// ErrNoRepo is returned by ListSnapshots when no repository is configured +// via flags, config environ, or process environment. +var ErrNoRepo = errors.New("no repository configured (need --repo, --repository-file, or RESTIC_REPOSITORY)") + // globalFlags is the set of restic global flags that should be forwarded // when running `restic snapshots` to list available snapshots. These are // the connection, auth, cache, and TLS flags shared across all restic @@ -43,9 +49,10 @@ var globalFlags = map[string]bool{ "--tls-client-cert": true, } -// hasRepoSource reports whether the resolved config provides a repository -// location, either via a flag (--repo, -r, --repository-file) or via -// the RESTIC_REPOSITORY / RESTIC_REPOSITORY_FILE environment variables. +// hasRepoSource reports whether a repository location is available from +// any source: CLI flags in the resolved config, the config's environ +// section, or the process environment (e.g. RESTIC_REPOSITORY already +// set in the user's shell). func hasRepoSource(cfg *config.ResolvedConfig) bool { for _, f := range cfg.Flags { switch f.Name { @@ -61,6 +68,11 @@ func hasRepoSource(cfg *config.ResolvedConfig) bool { return true } } + // Check the process environment as a last resort — the user may + // have RESTIC_REPOSITORY set in their shell outside of keld config. + if os.Getenv("RESTIC_REPOSITORY") != "" || os.Getenv("RESTIC_REPOSITORY_FILE") != "" { + return true + } return false } @@ -70,7 +82,7 @@ func hasRepoSource(cfg *config.ResolvedConfig) bool { // is available. func buildSnapshotCmd(cfg *config.ResolvedConfig) ([]string, error) { if !hasRepoSource(cfg) { - return nil, fmt.Errorf("no repository configured (need --repo, --repository-file, or RESTIC_REPOSITORY)") + return nil, ErrNoRepo } argv := []string{executable(), "snapshots", "--json"} diff --git a/internal/restic/list_snapshots_test.go b/internal/restic/list_snapshots_test.go index 21ca04fd4008d3ece00b297477f15f3e11f63e47..d33de4e338eeea82f4fdd8f3e94fb0145402a528 100644 --- a/internal/restic/list_snapshots_test.go +++ b/internal/restic/list_snapshots_test.go @@ -1,6 +1,7 @@ package restic import ( + "errors" "testing" "git.secluded.site/keld/internal/config" @@ -61,16 +62,6 @@ func TestBuildSnapshotCmd(t *testing.T) { "--no-lock", }, }, - { - name: "no repo flag", - cfg: &config.ResolvedConfig{ - Command: "restore", - Flags: []config.Flag{ - {Name: "--target", Value: "/tmp/restore"}, - }, - }, - wantErr: true, - }, { name: "repo via repository-file", cfg: &config.ResolvedConfig{ @@ -168,6 +159,54 @@ func TestBuildSnapshotCmd(t *testing.T) { } } +func TestBuildSnapshotCmdNoRepoSentinel(t *testing.T) { + // Not parallel: uses t.Setenv to control process environment. + t.Setenv("RESTIC_REPOSITORY", "") + t.Setenv("RESTIC_REPOSITORY_FILE", "") + + cfg := &config.ResolvedConfig{ + Command: "restore", + Flags: []config.Flag{ + {Name: "--target", Value: "/tmp/restore"}, + }, + } + + _, err := buildSnapshotCmd(cfg) + if !errors.Is(err, ErrNoRepo) { + t.Errorf("expected ErrNoRepo, got %v", err) + } +} + +func TestBuildSnapshotCmdProcessEnv(t *testing.T) { + // Not parallel: uses t.Setenv to control process environment. + t.Setenv("RESTIC_REPOSITORY", "/srv/from-env") + t.Setenv("RESTIC_REPOSITORY_FILE", "") + + cfg := &config.ResolvedConfig{ + Command: "restore", + Flags: []config.Flag{ + {Name: "--target", Value: "/tmp/restore"}, + }, + } + + argv, err := buildSnapshotCmd(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // No --repo flag in argv since repo comes from process env; + // restic reads it directly. + wantArgv := []string{"restic", "snapshots", "--json"} + if len(argv) != len(wantArgv) { + t.Fatalf("argv: got %v, want %v", argv, wantArgv) + } + for i := range argv { + if argv[i] != wantArgv[i] { + t.Errorf("argv[%d]: got %q, want %q", i, argv[i], wantArgv[i]) + } + } +} + func TestCopyEnviron(t *testing.T) { t.Parallel()