From 9903b69819eac2d0f263c7a3c4255b72e5d5b03c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20P=C3=A9rez?= Date: Tue, 30 Aug 2022 18:14:01 +0200 Subject: [PATCH 1/4] Implement RemovePath and pebble rm command --- client/files.go | 98 ++++++++++++++++++++ client/files_test.go | 188 ++++++++++++++++++++++++++++++++++++++ cmd/pebble/cmd_help.go | 2 +- cmd/pebble/cmd_rm.go | 57 ++++++++++++ cmd/pebble/cmd_rm_test.go | 157 +++++++++++++++++++++++++++++++ 5 files changed, 501 insertions(+), 1 deletion(-) create mode 100644 client/files.go create mode 100644 client/files_test.go create mode 100644 cmd/pebble/cmd_rm.go create mode 100644 cmd/pebble/cmd_rm_test.go diff --git a/client/files.go b/client/files.go new file mode 100644 index 000000000..70209169d --- /dev/null +++ b/client/files.go @@ -0,0 +1,98 @@ +// Copyright (c) 2022 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package client + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// RemovePathOptions holds the options for a call to RemovePath. +type RemovePathOptions struct { + // Path is the absolute path to be deleted (required). + Path string + + // MakeParents, if true, specifies that any non-existent parent directories + // should be created. If false (the default), the call will fail if the + // directory to be created has at least one parent directory that does not + // exist. + Recursive bool +} + +type removePathsPayload struct { + Action string `json:"action"` + Paths []removePathsItem `json:"paths"` +} + +type removePathsItem struct { + Path string `json:"path"` + Recursive bool `json:"recursive"` +} + +type fileResult struct { + Path string `json:"path"` + Error *errorResult `json:"error,omitempty"` +} + +type errorResult struct { + Message string `json:"message"` + Kind string `json:"kind,omitempty"` + Value interface{} `json:"value,omitempty"` +} + +// RemovePath deletes files and directories. +// The error returned is a *Error if the request went through successfully +// but there was an OS-level error creating the directory, with the Kind +// field set to the specific error kind, for example "permission-denied". +func (client *Client) RemovePath(opts *RemovePathOptions) error { + payload := &removePathsPayload{ + Action: "remove", + Paths: []removePathsItem{ + { + Path: opts.Path, + Recursive: opts.Recursive, + }, + }, + } + + var body bytes.Buffer + err := json.NewEncoder(&body).Encode(&payload) + if err != nil { + return err + } + + var result []fileResult + _, err = client.doSync("POST", "/v1/files", nil, map[string]string{ + "Content-Type": "application/json", + }, &body, &result) + if err != nil { + return err + } + + if len(result) != 1 { + return fmt.Errorf("expected exactly one result from API, got %d", len(result)) + } + + if result[0].Error != nil { + return &Error{ + Kind: result[0].Error.Kind, + Value: result[0].Error.Value, + Message: result[0].Error.Message, + } + } + + return nil +} diff --git a/client/files_test.go b/client/files_test.go new file mode 100644 index 000000000..f0943b4a7 --- /dev/null +++ b/client/files_test.go @@ -0,0 +1,188 @@ +// Copyright (c) 2022 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package client_test + +import ( + "encoding/json" + + . "gopkg.in/check.v1" + + "github.com/canonical/pebble/client" +) + +type removePathsPayload struct { + Action string `json:"action"` + Paths []removePathsItem `json:"paths"` +} + +type removePathsItem struct { + Path string `json:"path"` + Recursive bool `json:"recursive"` +} + +func (cs *clientSuite) TestRemovePath(c *C) { + cs.rsp = `{"type": "sync", "result": [{"path": "/foo/bar"}]}` + + err := cs.cli.RemovePath(&client.RemovePathOptions{ + Path: "/foo/bar", + }) + c.Assert(err, IsNil) + + c.Assert(cs.req.URL.Path, Equals, "/v1/files") + c.Assert(cs.req.Method, Equals, "POST") + + var payload removePathsPayload + decoder := json.NewDecoder(cs.req.Body) + err = decoder.Decode(&payload) + c.Assert(err, IsNil) + c.Check(payload, DeepEquals, removePathsPayload{ + Action: "remove", + Paths: []removePathsItem{{ + Path: "/foo/bar", + }}, + }) +} + +func (cs *clientSuite) TestRemovePathRecursive(c *C) { + cs.rsp = `{"type": "sync", "result": [{"path": "/foo/bar"}]}` + + err := cs.cli.RemovePath(&client.RemovePathOptions{ + Path: "/foo/bar", + Recursive: true, + }) + c.Assert(err, IsNil) + + c.Assert(cs.req.URL.Path, Equals, "/v1/files") + c.Assert(cs.req.Method, Equals, "POST") + + var payload removePathsPayload + decoder := json.NewDecoder(cs.req.Body) + err = decoder.Decode(&payload) + c.Assert(err, IsNil) + c.Check(payload, DeepEquals, removePathsPayload{ + Action: "remove", + Paths: []removePathsItem{{ + Path: "/foo/bar", + Recursive: true, + }}, + }) +} + +func (cs *clientSuite) TestRemovePathFails(c *C) { + cs.rsp = `{"type": "error", "result": {"message": "could not foo"}}` + err := cs.cli.RemovePath(&client.RemovePathOptions{ + Path: "/foobar", + }) + c.Assert(err, ErrorMatches, "could not foo") + + c.Assert(cs.req.URL.Path, Equals, "/v1/files") + c.Assert(cs.req.Method, Equals, "POST") + + var payload removePathsPayload + decoder := json.NewDecoder(cs.req.Body) + err = decoder.Decode(&payload) + c.Assert(err, IsNil) + c.Check(payload, DeepEquals, removePathsPayload{ + Action: "remove", + Paths: []removePathsItem{{ + Path: "/foobar", + }}, + }) +} + +func (cs *clientSuite) TestRemovePathFailsOnPath(c *C) { + cs.rsp = ` +{ + "type": "sync", + "result": [ + { + "path": "/foo/bar/baz.qux", + "error": { + "message": "could not bar", + "kind": "permission-denied", + "value": 42 + } + } + ] +}` + + err := cs.cli.RemovePath(&client.RemovePathOptions{ + Path: "/foo/bar", + Recursive: true, + }) + clientErr, ok := err.(*client.Error) + c.Assert(ok, Equals, true) + c.Assert(clientErr.Message, Equals, "could not bar") + c.Assert(clientErr.Kind, Equals, "permission-denied") + + c.Assert(cs.req.URL.Path, Equals, "/v1/files") + c.Assert(cs.req.Method, Equals, "POST") + + var payload removePathsPayload + decoder := json.NewDecoder(cs.req.Body) + err = decoder.Decode(&payload) + c.Assert(err, IsNil) + c.Check(payload, DeepEquals, removePathsPayload{ + Action: "remove", + Paths: []removePathsItem{{ + Path: "/foo/bar", + Recursive: true, + }}, + }) +} + +func (cs *clientSuite) TestMakeDirFailsWithMultipleAPIResults(c *C) { + cs.rsp = ` +{ + "type": "sync", + "result": [ + { + "path": "/foobar", + "error": { + "message": "could not bar", + "kind": "permission-denied", + "value": 42 + } + }, + { + "path": "/foobar", + "error": { + "message": "could not baz", + "kind": "generic-file-error", + "value": 47 + } + } + ] +}` + + err := cs.cli.RemovePath(&client.RemovePathOptions{ + Path: "/foobar", + }) + c.Assert(err, ErrorMatches, "expected exactly one result from API, got 2") + + c.Assert(cs.req.URL.Path, Equals, "/v1/files") + c.Assert(cs.req.Method, Equals, "POST") + + var payload removePathsPayload + decoder := json.NewDecoder(cs.req.Body) + err = decoder.Decode(&payload) + c.Assert(err, IsNil) + c.Check(payload, DeepEquals, removePathsPayload{ + Action: "remove", + Paths: []removePathsItem{{ + Path: "/foobar", + }}, + }) +} diff --git a/cmd/pebble/cmd_help.go b/cmd/pebble/cmd_help.go index e5501e841..131dd8995 100644 --- a/cmd/pebble/cmd_help.go +++ b/cmd/pebble/cmd_help.go @@ -180,7 +180,7 @@ var helpCategories = []helpCategory{{ }, { Label: "Files", Description: "work with files and execute commands", - Commands: []string{"exec"}, + Commands: []string{"rm", "exec"}, }, { Label: "Changes", Description: "manage changes and their tasks", diff --git a/cmd/pebble/cmd_rm.go b/cmd/pebble/cmd_rm.go new file mode 100644 index 000000000..1c4ec3673 --- /dev/null +++ b/cmd/pebble/cmd_rm.go @@ -0,0 +1,57 @@ +// Copyright (c) 2022 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package main + +import ( + "github.com/jessevdk/go-flags" + + "github.com/canonical/pebble/client" +) + +type cmdRm struct { + clientMixin + + Recursive bool `short:"r" long:"recursive"` + + Positional struct { + Path string `positional-arg-name:""` + } `positional-args:"yes" required:"yes"` +} + +var rmDescs = map[string]string{ + "recursive": "Create parent directories as needed.", +} + +var shortRmHelp = "Remove files and directories" +var longRmHelp = ` +The rm command removes a file or directory. +If --recursive is specified, all files and directories contained +within the specified path will also be removed. +` + +func (cmd *cmdRm) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + return cmd.client.RemovePath(&client.RemovePathOptions{ + Path: cmd.Positional.Path, + Recursive: cmd.Recursive, + }) +} + +func init() { + addCommand("rm", shortRmHelp, longRmHelp, func() flags.Commander { return &cmdRm{} }, rmDescs, nil) +} diff --git a/cmd/pebble/cmd_rm_test.go b/cmd/pebble/cmd_rm_test.go new file mode 100644 index 000000000..899edfa29 --- /dev/null +++ b/cmd/pebble/cmd_rm_test.go @@ -0,0 +1,157 @@ +// Copyright (c) 2022 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package main_test + +import ( + "fmt" + "net/http" + + . "gopkg.in/check.v1" + + "github.com/canonical/pebble/client" + pebble "github.com/canonical/pebble/cmd/pebble" +) + +func (s *PebbleSuite) TestRmExtraArgs(c *C) { + rest, err := pebble.Parser(pebble.Client()).ParseArgs([]string{"rm", "extra", "args"}) + c.Assert(err, Equals, pebble.ErrExtraArgs) + c.Assert(rest, HasLen, 1) + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "") +} + +func (s *PebbleSuite) TestRm(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "POST") + c.Check(r.URL.Path, Equals, "/v1/files") + + body := DecodedRequestBody(c, r) + c.Check(body, DeepEquals, map[string]interface{}{ + "action": "remove", + "paths": []interface{}{ + map[string]interface{}{ + "path": "/foo/bar.baz", + "recursive": false, + }, + }, + }) + + fmt.Fprintln(w, `{"type": "sync", "result": [{"path": "/foo/bar.baz"}]}`) + }) + + rest, err := pebble.Parser(pebble.Client()).ParseArgs([]string{"rm", "/foo/bar.baz"}) + c.Assert(err, IsNil) + c.Assert(rest, HasLen, 0) + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "") +} + +func (s *PebbleSuite) TestRmRecursive(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "POST") + c.Check(r.URL.Path, Equals, "/v1/files") + + body := DecodedRequestBody(c, r) + c.Check(body, DeepEquals, map[string]interface{}{ + "action": "remove", + "paths": []interface{}{ + map[string]interface{}{ + "path": "/foo/bar", + "recursive": true, + }, + }, + }) + + fmt.Fprintln(w, `{"type": "sync", "result": [{"path": "/foo/bar"}]}`) + }) + + rest, err := pebble.Parser(pebble.Client()).ParseArgs([]string{"rm", "-r", "/foo/bar"}) + c.Assert(err, IsNil) + c.Assert(rest, HasLen, 0) + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "") +} + +func (s *PebbleSuite) TestRmFails(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "POST") + c.Check(r.URL.Path, Equals, "/v1/files") + + body := DecodedRequestBody(c, r) + c.Check(body, DeepEquals, map[string]interface{}{ + "action": "remove", + "paths": []interface{}{ + map[string]interface{}{ + "path": "/foo/bar.baz", + "recursive": false, + }, + }, + }) + + fmt.Fprintln(w, `{"type": "error", "result": {"message": "could not foo"}}`) + }) + + rest, err := pebble.Parser(pebble.Client()).ParseArgs([]string{"rm", "/foo/bar.baz"}) + c.Assert(err, ErrorMatches, "could not foo") + c.Assert(rest, HasLen, 1) + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "") +} + +func (s *PebbleSuite) TestRmFailsOnPath(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "POST") + c.Check(r.URL.Path, Equals, "/v1/files") + + body := DecodedRequestBody(c, r) + c.Check(body, DeepEquals, map[string]interface{}{ + "action": "remove", + "paths": []interface{}{ + map[string]interface{}{ + "path": "/foo/bar", + "recursive": true, + }, + }, + }) + + fmt.Fprintln(w, ` +{ + "type": "sync", + "result": [ + { + "path": "/foo/bar/baz.qux", + "error": { + "message": "could not baz", + "kind": "permission-denied", + "value": 42 + } + } + ] +}`) + }) + + rest, err := pebble.Parser(pebble.Client()).ParseArgs([]string{"rm", "-r", "/foo/bar"}) + + clientErr, ok := err.(*client.Error) + c.Assert(ok, Equals, true) + c.Assert(clientErr.Message, Equals, "could not baz") + c.Assert(clientErr.Kind, Equals, "permission-denied") + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "") + + c.Assert(rest, HasLen, 1) + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "") +} From 6438eebbe2aca9d847baa16b37f131133582abb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20P=C3=A9rez?= Date: Wed, 31 Aug 2022 09:41:48 +0200 Subject: [PATCH 2/4] Fix comments and strings from mkdir on rm --- client/files.go | 10 ++++------ cmd/pebble/cmd_rm.go | 6 ++---- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/client/files.go b/client/files.go index 70209169d..a44b2387a 100644 --- a/client/files.go +++ b/client/files.go @@ -25,10 +25,8 @@ type RemovePathOptions struct { // Path is the absolute path to be deleted (required). Path string - // MakeParents, if true, specifies that any non-existent parent directories - // should be created. If false (the default), the call will fail if the - // directory to be created has at least one parent directory that does not - // exist. + // Recursive, if true, will delete all files and directories contained + // within the specified path, recursively. Defaults to false. Recursive bool } @@ -53,9 +51,9 @@ type errorResult struct { Value interface{} `json:"value,omitempty"` } -// RemovePath deletes files and directories. +// RemovePath deletes a file or directory. // The error returned is a *Error if the request went through successfully -// but there was an OS-level error creating the directory, with the Kind +// but there was an OS-level error deleting a file or directory, with the Kind // field set to the specific error kind, for example "permission-denied". func (client *Client) RemovePath(opts *RemovePathOptions) error { payload := &removePathsPayload{ diff --git a/cmd/pebble/cmd_rm.go b/cmd/pebble/cmd_rm.go index 1c4ec3673..04b8f7538 100644 --- a/cmd/pebble/cmd_rm.go +++ b/cmd/pebble/cmd_rm.go @@ -31,14 +31,12 @@ type cmdRm struct { } var rmDescs = map[string]string{ - "recursive": "Create parent directories as needed.", + "recursive": "Remove all files and directories contained within the specified path.", } -var shortRmHelp = "Remove files and directories" +var shortRmHelp = "Remove a file or directory." var longRmHelp = ` The rm command removes a file or directory. -If --recursive is specified, all files and directories contained -within the specified path will also be removed. ` func (cmd *cmdRm) Execute(args []string) error { From 1e13683a09066e5cacf8850f5623e9b734153984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=83ngel=20P=C3=A9rez?= Date: Wed, 21 Sep 2022 13:50:30 +0200 Subject: [PATCH 3/4] Remove duplicated client.Error and --recursive --- client/files.go | 10 ++-------- cmd/pebble/cmd_rm.go | 4 ++-- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/client/files.go b/client/files.go index a44b2387a..cc92412f4 100644 --- a/client/files.go +++ b/client/files.go @@ -41,14 +41,8 @@ type removePathsItem struct { } type fileResult struct { - Path string `json:"path"` - Error *errorResult `json:"error,omitempty"` -} - -type errorResult struct { - Message string `json:"message"` - Kind string `json:"kind,omitempty"` - Value interface{} `json:"value,omitempty"` + Path string `json:"path"` + Error *Error `json:"error,omitempty"` } // RemovePath deletes a file or directory. diff --git a/cmd/pebble/cmd_rm.go b/cmd/pebble/cmd_rm.go index 04b8f7538..11410e83f 100644 --- a/cmd/pebble/cmd_rm.go +++ b/cmd/pebble/cmd_rm.go @@ -23,7 +23,7 @@ import ( type cmdRm struct { clientMixin - Recursive bool `short:"r" long:"recursive"` + Recursive bool `short:"r"` Positional struct { Path string `positional-arg-name:""` @@ -31,7 +31,7 @@ type cmdRm struct { } var rmDescs = map[string]string{ - "recursive": "Remove all files and directories contained within the specified path.", + "r": "Remove all files and directories contained within the specified path.", } var shortRmHelp = "Remove a file or directory." From 11078b912e4174270f9161304689ac1a9dadeba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20P=C3=A9rez?= Date: Mon, 26 Sep 2022 09:20:08 +0200 Subject: [PATCH 4/4] Minor fixes * Reviewed error messages. * Reviewed JSON style. --- client/files.go | 12 +++++------- client/files_test.go | 29 +++++++++++------------------ cmd/pebble/cmd_rm.go | 2 +- cmd/pebble/cmd_rm_test.go | 25 +++++++++++-------------- 4 files changed, 28 insertions(+), 40 deletions(-) diff --git a/client/files.go b/client/files.go index cc92412f4..24ccb39bc 100644 --- a/client/files.go +++ b/client/files.go @@ -61,23 +61,21 @@ func (client *Client) RemovePath(opts *RemovePathOptions) error { } var body bytes.Buffer - err := json.NewEncoder(&body).Encode(&payload) - if err != nil { - return err + if err := json.NewEncoder(&body).Encode(&payload); err != nil { + return fmt.Errorf("cannot encode JSON payload: %w", err) } var result []fileResult - _, err = client.doSync("POST", "/v1/files", nil, map[string]string{ + headers := map[string]string{ "Content-Type": "application/json", - }, &body, &result) - if err != nil { + } + if _, err := client.doSync("POST", "/v1/files", nil, headers, &body, &result); err != nil { return err } if len(result) != 1 { return fmt.Errorf("expected exactly one result from API, got %d", len(result)) } - if result[0].Error != nil { return &Error{ Kind: result[0].Error.Kind, diff --git a/client/files_test.go b/client/files_test.go index f0943b4a7..daf8e24cf 100644 --- a/client/files_test.go +++ b/client/files_test.go @@ -103,20 +103,17 @@ func (cs *clientSuite) TestRemovePathFails(c *C) { } func (cs *clientSuite) TestRemovePathFailsOnPath(c *C) { - cs.rsp = ` -{ - "type": "sync", - "result": [ - { + cs.rsp = ` { + "type": "sync", + "result": [{ "path": "/foo/bar/baz.qux", "error": { "message": "could not bar", "kind": "permission-denied", "value": 42 } - } - ] -}` + }] + }` err := cs.cli.RemovePath(&client.RemovePathOptions{ Path: "/foo/bar", @@ -144,28 +141,24 @@ func (cs *clientSuite) TestRemovePathFailsOnPath(c *C) { } func (cs *clientSuite) TestMakeDirFailsWithMultipleAPIResults(c *C) { - cs.rsp = ` -{ - "type": "sync", - "result": [ - { + cs.rsp = `{ + "type": "sync", + "result": [{ "path": "/foobar", "error": { "message": "could not bar", "kind": "permission-denied", "value": 42 } - }, - { + }, { "path": "/foobar", "error": { "message": "could not baz", "kind": "generic-file-error", "value": 47 } - } - ] -}` + }] + }` err := cs.cli.RemovePath(&client.RemovePathOptions{ Path: "/foobar", diff --git a/cmd/pebble/cmd_rm.go b/cmd/pebble/cmd_rm.go index 11410e83f..a2a3fe80d 100644 --- a/cmd/pebble/cmd_rm.go +++ b/cmd/pebble/cmd_rm.go @@ -31,7 +31,7 @@ type cmdRm struct { } var rmDescs = map[string]string{ - "r": "Remove all files and directories contained within the specified path.", + "r": "Remove all files and directories recursively in the specified path", } var shortRmHelp = "Remove a file or directory." diff --git a/cmd/pebble/cmd_rm_test.go b/cmd/pebble/cmd_rm_test.go index 899edfa29..bb8d4fa7b 100644 --- a/cmd/pebble/cmd_rm_test.go +++ b/cmd/pebble/cmd_rm_test.go @@ -126,20 +126,17 @@ func (s *PebbleSuite) TestRmFailsOnPath(c *C) { }, }) - fmt.Fprintln(w, ` -{ - "type": "sync", - "result": [ - { - "path": "/foo/bar/baz.qux", - "error": { - "message": "could not baz", - "kind": "permission-denied", - "value": 42 - } - } - ] -}`) + fmt.Fprintln(w, ` { + "type": "sync", + "result": [{ + "path": "/foo/bar/baz.qux", + "error": { + "message": "could not baz", + "kind": "permission-denied", + "value": 42 + } + }] + }`) }) rest, err := pebble.Parser(pebble.Client()).ParseArgs([]string{"rm", "-r", "/foo/bar"})