cmd/interactive.go 🔗
@@ -20,4 +20,4 @@ func isInteractive() bool {
return false
}
return isStdinTerminal()
-}
+}
Amolith created
When running non-interactively, keld now validates that required inputs
are present before executing restic. Two cases checked:
- Multiple presets with no --preset specified: returns an error listing
available presets instead of silently using global defaults.
- backup with no paths: returns an error directing user to pass paths
as arguments or set _arguments in the preset.
Task: td-H0EDND6
cmd/interactive.go | 2
cmd/interactive_test.go | 2
cmd/noninteractive_errors_test.go | 112 +++++++++++++++++++++++++++++++++
cmd/root.go | 16 ++++
cmd/root_noninteractive_test.go | 2
cmd/subcommands_test.go | 3
6 files changed, 132 insertions(+), 5 deletions(-)
@@ -20,4 +20,4 @@ func isInteractive() bool {
return false
}
return isStdinTerminal()
-}
+}
@@ -71,4 +71,4 @@ func TestIsInteractive(t *testing.T) {
}
})
}
-}
+}
@@ -0,0 +1,112 @@
+package cmd
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestBackupNonInteractiveMissingPaths(t *testing.T) {
+ // Not parallel: mutates package-level isStdinTerminal.
+
+ original := isStdinTerminal
+ t.Cleanup(func() {
+ isStdinTerminal = original
+ })
+ isStdinTerminal = func() bool { return false }
+
+ // Config with a valid preset but no backup paths.
+ dir := t.TempDir()
+ cfg := filepath.Join(dir, "config.toml")
+ err := os.WriteFile(cfg, []byte(`
+[global]
+repo = "/repos/default"
+
+["home@"]
+tag = "home"
+
+["@cloud"]
+repo = "/repos/cloud"
+`), 0o600)
+ if err != nil {
+ t.Fatalf("writing fixture: %v", err)
+ }
+ t.Setenv("HOME", dir)
+
+ setRootFlagValuesForTest(t, "home@cloud", true, cfg)
+ t.Setenv("KELD_CONFIG_FILE", "")
+ t.Setenv("KELD_DRYRUN", "")
+
+ backup := lookupSubcommand(t, "backup")
+ _, err = captureStdout(t, func() error {
+ return backup.RunE(backup, []string{})
+ })
+
+ if err == nil {
+ t.Fatal("expected error for backup with no paths, got nil")
+ }
+
+ errMsg := err.Error()
+ // Should mention paths specifically.
+ if !strings.Contains(errMsg, "path") {
+ t.Fatalf("expected error to mention paths, got: %v", err)
+ }
+}
+
+func TestBackupNonInteractiveMultiplePresetsNoSelection(t *testing.T) {
+ // Not parallel: mutates package-level isStdinTerminal.
+
+ original := isStdinTerminal
+ t.Cleanup(func() {
+ isStdinTerminal = original
+ })
+ isStdinTerminal = func() bool { return false }
+
+ // Config with multiple presets (home@cloud, work@cloud).
+ dir := t.TempDir()
+ cfg := filepath.Join(dir, "config.toml")
+ err := os.WriteFile(cfg, []byte(`
+[global]
+repo = "/repos/default"
+
+["home@"]
+tag = "home"
+
+["home@".backup]
+_arguments = "/home"
+
+["work@"]
+tag = "work"
+
+["work@".backup]
+_arguments = "/work"
+
+["@cloud"]
+repo = "/repos/cloud"
+`), 0o600)
+ if err != nil {
+ t.Fatalf("writing fixture: %v", err)
+ }
+ t.Setenv("HOME", dir)
+
+ // No --preset specified.
+ setRootFlagValuesForTest(t, "", true, cfg)
+ t.Setenv("KELD_CONFIG_FILE", "")
+ t.Setenv("KELD_DRYRUN", "")
+
+ backup := lookupSubcommand(t, "backup")
+ _, err = captureStdout(t, func() error {
+ return backup.RunE(backup, []string{})
+ })
+
+ if err == nil {
+ t.Fatal("expected error for multiple presets with no selection, got nil")
+ }
+
+ errMsg := err.Error()
+ // Should mention multiple presets.
+ if !strings.Contains(errMsg, "preset") {
+ t.Fatalf("expected error to mention presets, got: %v", err)
+ }
+}
@@ -100,6 +100,15 @@ func runCommand(commandName string, cmd *cobra.Command, rawArgs []string, sessio
preset = *sessionPreset
}
+ // In non-interactive mode with no preset and multiple presets
+ // available, require explicit --preset selection.
+ if !interactive && preset == "" {
+ presets := config.Presets()
+ if len(presets) > 1 {
+ return fmt.Errorf("keld: multiple presets defined (%s); specify --preset in non-interactive mode", strings.Join(presets, ", "))
+ }
+ }
+
if preset != "" {
if err := validatePreset(preset); err != nil {
return err
@@ -131,6 +140,13 @@ func runCommand(commandName string, cmd *cobra.Command, rawArgs []string, sessio
return err
}
+ // In non-interactive mode, validate that required inputs are present.
+ if !interactive {
+ if commandName == "backup" && len(cfg.Arguments) == 0 {
+ return fmt.Errorf("keld backup: no paths specified; pass paths as arguments or set _arguments in the preset")
+ }
+ }
+
restic.WarnUnknownFlags(cfg.Command, cfg.Flags)
if config.IsDryRun() {
@@ -35,4 +35,4 @@ func TestRootRunNonInteractiveRequiresSubcommand(t *testing.T) {
if !strings.Contains(errMsg, "subcommand") {
t.Fatalf("expected error to mention subcommand, got: %v", err)
}
-}
+}
@@ -27,7 +27,6 @@ func TestSubcommandNoArgsNonInteractiveRunsDirectly(t *testing.T) {
out, err := captureStdout(t, func() error {
return backup.RunE(backup, []string{})
})
-
// Should NOT get a bubbletea TTY error. Instead, should either:
// - Succeed with dry-run output (if config has paths), or
// - Fail with a sensible error about missing inputs.
@@ -112,4 +111,4 @@ func TestSubcommandNoArgsInteractiveEntersSession(t *testing.T) {
if !strings.Contains(errMsg, "TTY") && !strings.Contains(errMsg, "tty") {
t.Fatalf("expected bubbletea TTY error in interactive mode, got: %v", err)
}
-}
+}