From ef2615089b5035dc9299fad515344d8fe6d0869a Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Wed, 8 Jul 2026 15:28:33 -0700 Subject: [PATCH] Extract shared mv/cp relocation driver mv and cp had ~115 lines of near-identical body: the same two-phase validate/execute loop, destination resolution, skip and conflict handling, dry-run preview, and JSON accumulation, differing only in a handful of values (command name, verb, success status, missing-args message, and which API call to make). Introduce a relocationSpec describing those differences and a single runRelocation driver in relocation.go. mv and cp become one-line delegations to runRelocation with moveSpec / copySpec. No behavior change: all command-specific strings (including mv's distinct missing-args wording) are preserved via the spec. Add relocation_test.go covering runRelocation over both specs: the distinct missing-args messages, the dry-run/skip/result-build error branches (each asserting the correct verb), and Move-vs-Copy API routing with the correct status. Raises runRelocation coverage from 85% to 97%; the only uncovered block is a pre-existing unreachable validation branch. --- cmd/cp.go | 116 +----------------------- cmd/mv.go | 115 +----------------------- cmd/relocation.go | 168 +++++++++++++++++++++++++++++++++++ cmd/relocation_test.go | 197 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 367 insertions(+), 229 deletions(-) create mode 100644 cmd/relocation.go create mode 100644 cmd/relocation_test.go diff --git a/cmd/cp.go b/cmd/cp.go index 7b107b2..654291f 100644 --- a/cmd/cp.go +++ b/cmd/cp.go @@ -15,125 +15,11 @@ package cmd import ( - "fmt" - "io" - "strings" - - "github.com/dropbox/dbxcli/v3/internal/output" - "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" "github.com/spf13/cobra" ) func cp(cmd *cobra.Command, args []string) error { - var destination string - var argsToCopy []string - - if len(args) > 2 { - destination = args[len(args)-1] - argsToCopy = args[0 : len(args)-1] - } else if len(args) == 2 { - destination = args[1] - argsToCopy = append(argsToCopy, args[0]) - } else { - return invalidArgumentsErrorWithDetails("cp requires a source and a destination", argumentsErrorDetails("source", "destination")) - } - - opts, err := parseRelocationOptions(cmd) - if err != nil { - return err - } - - var cpErrors []error - var cpErrorDetails []map[string]any - var relocationArgs []*files.RelocationArg - var plannedResults []relocationResult - var results []jsonOperationResult - collectResults := commandOutputFormat(cmd) == output.FormatJSON - - dbx := filesNewFunc(config) - destIsFolder := len(argsToCopy) > 1 || strings.HasSuffix(destination, "/") || isRemoteFolder(dbx, destination) - - for _, argument := range argsToCopy { - dst := relocationDestination(argument, destination, destIsFolder) - arg, err := makeRelocationArg(argument, dst) - if err != nil { - relocationError := fmt.Errorf("Error validating copy for %s to %s: %v", argument, dst, err) - cpErrors = append(cpErrors, relocationError) - cpErrorDetails = append(cpErrorDetails, relocationFailureDetails(argument, dst)) - } else { - arg.Autorename = opts.ifExists == relocationIfExistsAutorename - if opts.dryRun { - result, err := plannedRelocationResult(dbx, arg) - if err != nil { - cpErrors = append(cpErrors, fmt.Errorf("copy %q to %q: %v", arg.FromPath, arg.ToPath, err)) - cpErrorDetails = append(cpErrorDetails, relocationFailureDetails(arg.FromPath, arg.ToPath)) - continue - } - plannedResults = append(plannedResults, result) - if collectResults { - results = append(results, relocationOperationResult(relocationJSONStatusCopied, result)) - } - continue - } - result, skipped, err := relocationSkipIfDestinationExists(dbx, arg, opts) - if err != nil { - cpErrors = append(cpErrors, fmt.Errorf("copy %q to %q: %v", arg.FromPath, arg.ToPath, err)) - cpErrorDetails = append(cpErrorDetails, relocationFailureDetails(arg.FromPath, arg.ToPath)) - continue - } - if skipped { - if collectResults { - results = append(results, relocationOperationResult(relocationJSONStatusSkipped, result)) - } - continue - } - relocationArgs = append(relocationArgs, arg) - } - } - - for _, arg := range relocationArgs { - res, err := dbx.CopyV2Context(currentContext(), arg) - if err != nil { - if result, skipped := relocationSkipAfterDestinationConflict(dbx, arg, err, opts); skipped { - if collectResults { - results = append(results, relocationOperationResult(relocationJSONStatusSkipped, result)) - } - continue - } - copyError := fmt.Errorf("copy %q to %q: %v", arg.FromPath, arg.ToPath, err) - cpErrors = append(cpErrors, copyError) - cpErrorDetails = append(cpErrorDetails, relocationFailureDetails(arg.FromPath, arg.ToPath)) - continue - } - if collectResults { - result, err := newRelocationResult(arg, res) - if err != nil { - copyError := fmt.Errorf("copy %q to %q: %v", arg.FromPath, arg.ToPath, err) - cpErrors = append(cpErrors, copyError) - cpErrorDetails = append(cpErrorDetails, relocationFailureDetails(arg.FromPath, arg.ToPath)) - continue - } - results = append(results, relocationOperationResult(relocationSuccessStatus(relocationJSONStatusCopied, arg, result, opts), result)) - } - } - - if len(cpErrors) > 0 { - for _, cpError := range cpErrors { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%v\n", cpError) - } - return relocationAggregateError("cp", "copy", len(cpErrors), cpErrorDetails) - } - - if opts.dryRun { - return commandOutput(cmd).Render(func(w io.Writer) error { - return renderPlannedRelocationResults(w, "copy", plannedResults) - }, newJSONCommandOperationOutput(cmd, nil, results, nil)) - } - - if !collectResults { - return nil - } - return renderJSONOperationOutput(cmd, nil, results) + return runRelocation(cmd, args, copySpec) } // cpCmd represents the cp command diff --git a/cmd/mv.go b/cmd/mv.go index 23c2704..0f95820 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -15,124 +15,11 @@ package cmd import ( - "fmt" - "io" - "strings" - - "github.com/dropbox/dbxcli/v3/internal/output" - "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" "github.com/spf13/cobra" ) func mv(cmd *cobra.Command, args []string) error { - var destination string - var argsToMove []string - - if len(args) > 2 { - destination = args[len(args)-1] - argsToMove = args[0 : len(args)-1] - } else if len(args) == 2 { - destination = args[1] - argsToMove = append(argsToMove, args[0]) - } else { - return invalidArgumentsErrorWithDetails("mv command requires a source and a destination", argumentsErrorDetails("source", "destination")) - } - - opts, err := parseRelocationOptions(cmd) - if err != nil { - return err - } - - var mvErrors []error - var mvErrorDetails []map[string]any - var relocationArgs []*files.RelocationArg - var plannedResults []relocationResult - var results []jsonOperationResult - collectResults := commandOutputFormat(cmd) == output.FormatJSON - - dbx := filesNewFunc(config) - destIsFolder := len(argsToMove) > 1 || strings.HasSuffix(destination, "/") || isRemoteFolder(dbx, destination) - - for _, argument := range argsToMove { - dst := relocationDestination(argument, destination, destIsFolder) - arg, err := makeRelocationArg(argument, dst) - if err != nil { - mvErrors = append(mvErrors, fmt.Errorf("Error validating move for %s to %s: %v", argument, dst, err)) - mvErrorDetails = append(mvErrorDetails, relocationFailureDetails(argument, dst)) - } else { - arg.Autorename = opts.ifExists == relocationIfExistsAutorename - if opts.dryRun { - result, err := plannedRelocationResult(dbx, arg) - if err != nil { - mvErrors = append(mvErrors, fmt.Errorf("move %q to %q: %v", arg.FromPath, arg.ToPath, err)) - mvErrorDetails = append(mvErrorDetails, relocationFailureDetails(arg.FromPath, arg.ToPath)) - continue - } - plannedResults = append(plannedResults, result) - if collectResults { - results = append(results, relocationOperationResult(relocationJSONStatusMoved, result)) - } - continue - } - result, skipped, err := relocationSkipIfDestinationExists(dbx, arg, opts) - if err != nil { - mvErrors = append(mvErrors, fmt.Errorf("move %q to %q: %v", arg.FromPath, arg.ToPath, err)) - mvErrorDetails = append(mvErrorDetails, relocationFailureDetails(arg.FromPath, arg.ToPath)) - continue - } - if skipped { - if collectResults { - results = append(results, relocationOperationResult(relocationJSONStatusSkipped, result)) - } - continue - } - relocationArgs = append(relocationArgs, arg) - } - } - - for _, arg := range relocationArgs { - res, err := dbx.MoveV2Context(currentContext(), arg) - if err != nil { - if result, skipped := relocationSkipAfterDestinationConflict(dbx, arg, err, opts); skipped { - if collectResults { - results = append(results, relocationOperationResult(relocationJSONStatusSkipped, result)) - } - continue - } - moveError := fmt.Errorf("move %q to %q: %v", arg.FromPath, arg.ToPath, err) - mvErrors = append(mvErrors, moveError) - mvErrorDetails = append(mvErrorDetails, relocationFailureDetails(arg.FromPath, arg.ToPath)) - continue - } - if collectResults { - result, err := newRelocationResult(arg, res) - if err != nil { - moveError := fmt.Errorf("move %q to %q: %v", arg.FromPath, arg.ToPath, err) - mvErrors = append(mvErrors, moveError) - mvErrorDetails = append(mvErrorDetails, relocationFailureDetails(arg.FromPath, arg.ToPath)) - continue - } - results = append(results, relocationOperationResult(relocationSuccessStatus(relocationJSONStatusMoved, arg, result, opts), result)) - } - } - - if len(mvErrors) > 0 { - for _, mvError := range mvErrors { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%v\n", mvError) - } - return relocationAggregateError("mv", "move", len(mvErrors), mvErrorDetails) - } - - if opts.dryRun { - return commandOutput(cmd).Render(func(w io.Writer) error { - return renderPlannedRelocationResults(w, "move", plannedResults) - }, newJSONCommandOperationOutput(cmd, nil, results, nil)) - } - - if !collectResults { - return nil - } - return renderJSONOperationOutput(cmd, nil, results) + return runRelocation(cmd, args, moveSpec) } // mvCmd represents the mv command diff --git a/cmd/relocation.go b/cmd/relocation.go new file mode 100644 index 0000000..dd954b1 --- /dev/null +++ b/cmd/relocation.go @@ -0,0 +1,168 @@ +// Copyright © 2016 Dropbox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "fmt" + "io" + "strings" + + "github.com/dropbox/dbxcli/v3/internal/output" + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" + "github.com/spf13/cobra" +) + +// relocationSpec captures the handful of values that differ between the mv and +// cp commands so they can share the single runRelocation driver below. The rest +// of the two commands (two-phase validate/execute loop, destination resolution, +// skip and conflict handling, dry-run preview, JSON accumulation) is identical. +type relocationSpec struct { + command string // "mv" / "cp"; used in the aggregate error + verb string // "move" / "copy"; used in per-operation error messages and dry-run text + missingArgsMessage string // shown when fewer than two positional args are given + successStatus string // relocationJSONStatusMoved / relocationJSONStatusCopied + execute func(dbx filesClient, arg *files.RelocationArg) (*files.RelocationResult, error) +} + +var moveSpec = relocationSpec{ + command: "mv", + verb: "move", + missingArgsMessage: "mv command requires a source and a destination", + successStatus: relocationJSONStatusMoved, + execute: func(dbx filesClient, arg *files.RelocationArg) (*files.RelocationResult, error) { + return dbx.MoveV2Context(currentContext(), arg) + }, +} + +var copySpec = relocationSpec{ + command: "cp", + verb: "copy", + missingArgsMessage: "cp requires a source and a destination", + successStatus: relocationJSONStatusCopied, + execute: func(dbx filesClient, arg *files.RelocationArg) (*files.RelocationResult, error) { + return dbx.CopyV2Context(currentContext(), arg) + }, +} + +// runRelocation implements the shared body of the mv and cp commands, using +// spec for the command-specific verb, status, error text, and API call. +func runRelocation(cmd *cobra.Command, args []string, spec relocationSpec) error { + var destination string + var argsToRelocate []string + + if len(args) > 2 { + destination = args[len(args)-1] + argsToRelocate = args[0 : len(args)-1] + } else if len(args) == 2 { + destination = args[1] + argsToRelocate = append(argsToRelocate, args[0]) + } else { + return invalidArgumentsErrorWithDetails(spec.missingArgsMessage, argumentsErrorDetails("source", "destination")) + } + + opts, err := parseRelocationOptions(cmd) + if err != nil { + return err + } + + var relocationErrors []error + var relocationErrorDetails []map[string]any + var relocationArgs []*files.RelocationArg + var plannedResults []relocationResult + var results []jsonOperationResult + collectResults := commandOutputFormat(cmd) == output.FormatJSON + + dbx := filesNewFunc(config) + destIsFolder := len(argsToRelocate) > 1 || strings.HasSuffix(destination, "/") || isRemoteFolder(dbx, destination) + + for _, argument := range argsToRelocate { + dst := relocationDestination(argument, destination, destIsFolder) + arg, err := makeRelocationArg(argument, dst) + if err != nil { + relocationErrors = append(relocationErrors, fmt.Errorf("Error validating %s for %s to %s: %v", spec.verb, argument, dst, err)) + relocationErrorDetails = append(relocationErrorDetails, relocationFailureDetails(argument, dst)) + } else { + arg.Autorename = opts.ifExists == relocationIfExistsAutorename + if opts.dryRun { + result, err := plannedRelocationResult(dbx, arg) + if err != nil { + relocationErrors = append(relocationErrors, fmt.Errorf("%s %q to %q: %v", spec.verb, arg.FromPath, arg.ToPath, err)) + relocationErrorDetails = append(relocationErrorDetails, relocationFailureDetails(arg.FromPath, arg.ToPath)) + continue + } + plannedResults = append(plannedResults, result) + if collectResults { + results = append(results, relocationOperationResult(spec.successStatus, result)) + } + continue + } + result, skipped, err := relocationSkipIfDestinationExists(dbx, arg, opts) + if err != nil { + relocationErrors = append(relocationErrors, fmt.Errorf("%s %q to %q: %v", spec.verb, arg.FromPath, arg.ToPath, err)) + relocationErrorDetails = append(relocationErrorDetails, relocationFailureDetails(arg.FromPath, arg.ToPath)) + continue + } + if skipped { + if collectResults { + results = append(results, relocationOperationResult(relocationJSONStatusSkipped, result)) + } + continue + } + relocationArgs = append(relocationArgs, arg) + } + } + + for _, arg := range relocationArgs { + res, err := spec.execute(dbx, arg) + if err != nil { + if result, skipped := relocationSkipAfterDestinationConflict(dbx, arg, err, opts); skipped { + if collectResults { + results = append(results, relocationOperationResult(relocationJSONStatusSkipped, result)) + } + continue + } + relocationErrors = append(relocationErrors, fmt.Errorf("%s %q to %q: %v", spec.verb, arg.FromPath, arg.ToPath, err)) + relocationErrorDetails = append(relocationErrorDetails, relocationFailureDetails(arg.FromPath, arg.ToPath)) + continue + } + if collectResults { + result, err := newRelocationResult(arg, res) + if err != nil { + relocationErrors = append(relocationErrors, fmt.Errorf("%s %q to %q: %v", spec.verb, arg.FromPath, arg.ToPath, err)) + relocationErrorDetails = append(relocationErrorDetails, relocationFailureDetails(arg.FromPath, arg.ToPath)) + continue + } + results = append(results, relocationOperationResult(relocationSuccessStatus(spec.successStatus, arg, result, opts), result)) + } + } + + if len(relocationErrors) > 0 { + for _, relocationError := range relocationErrors { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%v\n", relocationError) + } + return relocationAggregateError(spec.command, spec.verb, len(relocationErrors), relocationErrorDetails) + } + + if opts.dryRun { + return commandOutput(cmd).Render(func(w io.Writer) error { + return renderPlannedRelocationResults(w, spec.verb, plannedResults) + }, newJSONCommandOperationOutput(cmd, nil, results, nil)) + } + + if !collectResults { + return nil + } + return renderJSONOperationOutput(cmd, nil, results) +} diff --git a/cmd/relocation_test.go b/cmd/relocation_test.go new file mode 100644 index 0000000..6d5e991 --- /dev/null +++ b/cmd/relocation_test.go @@ -0,0 +1,197 @@ +// Copyright © 2016 Dropbox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" + "github.com/spf13/cobra" +) + +// relocationCommand invokes mv or cp through its command function so the tests +// below exercise the shared runRelocation driver via both specs. +type relocationCommand struct { + name string + verb string // verb woven into per-operation error messages + missingArgs string // exact missing-args message + run func(cmd *cobra.Command, args []string) error + successStatus string +} + +// relocationCommands returns mv and cp so each behavior can be table-tested +// against both, confirming the spec substitution (verb, status, API call, +// error text) is correct for each command. +func relocationCommands() []relocationCommand { + return []relocationCommand{ + {name: "mv", verb: "move", missingArgs: "mv command requires a source and a destination", run: mv, successStatus: relocationJSONStatusMoved}, + {name: "cp", verb: "copy", missingArgs: "cp requires a source and a destination", run: cp, successStatus: relocationJSONStatusCopied}, + } +} + +func TestRunRelocationMissingArgsMessagePerCommand(t *testing.T) { + for _, rc := range relocationCommands() { + t.Run(rc.name, func(t *testing.T) { + cmd := newRelocationTestCommand(nil, nil) + for _, args := range [][]string{{}, {"/only-one"}} { + err := rc.run(cmd, args) + if err == nil { + t.Fatalf("%s(%v): expected error, got nil", rc.name, args) + } + if !strings.Contains(err.Error(), rc.missingArgs) { + t.Fatalf("%s(%v) error = %q, want to contain %q", rc.name, args, err.Error(), rc.missingArgs) + } + } + }) + } +} + +func TestRunRelocationDryRunSourceLookupErrorUsesVerb(t *testing.T) { + for _, rc := range relocationCommands() { + t.Run(rc.name, func(t *testing.T) { + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + return nil, fmt.Errorf("boom") + }, + moveV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + t.Fatalf("%s: MoveV2 called during dry-run", rc.name) + return nil, nil + }, + copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + t.Fatalf("%s: CopyV2 called during dry-run", rc.name) + return nil, nil + }, + }) + + var stderr bytes.Buffer + cmd := newRelocationTextTestCommand(nil, &stderr) + if err := cmd.Flags().Set(dryRunFlagName, "true"); err != nil { + t.Fatal(err) + } + err := rc.run(cmd, []string{"/src/file.txt", "/dest/file.txt"}) + if err == nil { + t.Fatalf("%s: expected error from failed source lookup, got nil", rc.name) + } + if !strings.Contains(stderr.String(), rc.verb+" ") { + t.Fatalf("%s stderr = %q, want to contain verb %q", rc.name, stderr.String(), rc.verb) + } + }) + } +} + +func TestRunRelocationSkipCheckErrorUsesVerb(t *testing.T) { + for _, rc := range relocationCommands() { + t.Run(rc.name, func(t *testing.T) { + stubFilesClient(t, &mockFilesClient{ + // relocationSkipIfDestinationExists (via if-exists=skip) probes the + // destination with GetMetadata; a non-not-found error surfaces here. + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + return nil, fmt.Errorf("transient network failure") + }, + }) + + var stderr bytes.Buffer + cmd := newRelocationTextTestCommand(nil, &stderr) + if err := cmd.Flags().Set("if-exists", relocationIfExistsSkip); err != nil { + t.Fatal(err) + } + err := rc.run(cmd, []string{"/src/file.txt", "/dest/file.txt"}) + if err == nil { + t.Fatalf("%s: expected error from destination check, got nil", rc.name) + } + if !strings.Contains(stderr.String(), rc.verb+" ") { + t.Fatalf("%s stderr = %q, want to contain verb %q", rc.name, stderr.String(), rc.verb) + } + }) + } +} + +func TestRunRelocationResultBuildErrorUsesVerb(t *testing.T) { + for _, rc := range relocationCommands() { + t.Run(rc.name, func(t *testing.T) { + // A successful API call that returns metadata newRelocationResult + // cannot decode (a nil typed *FileMetadata) drives the post-execute + // result-building error branch. + okResult := &files.RelocationResult{Metadata: (*files.FileMetadata)(nil)} + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + return nil, relocationTestGetMetadataNotFoundError() + }, + moveV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + return okResult, nil + }, + copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + return okResult, nil + }, + }) + + var stderr bytes.Buffer + cmd := newRelocationTestCommand(nil, &stderr) // JSON mode: exercises collectResults path + err := rc.run(cmd, []string{"/src/file.txt", "/dest/file.txt"}) + if err == nil { + t.Fatalf("%s: expected result-build error, got nil", rc.name) + } + if !strings.Contains(stderr.String(), rc.verb+" ") { + t.Fatalf("%s stderr = %q, want to contain verb %q", rc.name, stderr.String(), rc.verb) + } + }) + } +} + +func TestRunRelocationCallsCorrectAPIForSpec(t *testing.T) { + for _, rc := range relocationCommands() { + t.Run(rc.name, func(t *testing.T) { + var moveCalls, copyCalls int + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + return nil, relocationTestGetMetadataNotFoundError() + }, + moveV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + moveCalls++ + return files.NewRelocationResult(relocationTestFileMetadata(arg.ToPath, 1)), nil + }, + copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + copyCalls++ + return files.NewRelocationResult(relocationTestFileMetadata(arg.ToPath, 1)), nil + }, + }) + + var stdout bytes.Buffer + cmd := newRelocationTestCommand(&stdout, nil) + if err := rc.run(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil { + t.Fatalf("%s error: %v", rc.name, err) + } + + wantMove, wantCopy := 0, 1 + if rc.name == "mv" { + wantMove, wantCopy = 1, 0 + } + if moveCalls != wantMove || copyCalls != wantCopy { + t.Fatalf("%s: moveCalls=%d copyCalls=%d, want %d/%d", rc.name, moveCalls, copyCalls, wantMove, wantCopy) + } + + got := decodeRelocationOutput(t, stdout.Bytes()) + if len(got.Results) != 1 { + t.Fatalf("%s: results = %d, want 1", rc.name, len(got.Results)) + } + if got.Results[0].Status != rc.successStatus { + t.Fatalf("%s: status = %q, want %q", rc.name, got.Results[0].Status, rc.successStatus) + } + }) + } +}