diff --git a/cmd/gendoc/docs.go b/cmd/gendoc/docs.go index ba8272373..e24ed30f5 100644 --- a/cmd/gendoc/docs.go +++ b/cmd/gendoc/docs.go @@ -1161,6 +1161,32 @@ Contains a JSON object with the details of an error. {StatusCode: http.StatusInternalServerError, Reference: "#/components/responses/InternalServerError"}, }, }, + { + OperationId: "renameAppLocalBrick", + Method: http.MethodPost, + Path: "/v1/apps/{appID}/bricks/{brickID}/rename", + Parameters: (*struct { + ID string `path:"appID" description:"application identifier."` + BrickID string `path:"brickID" description:"brick identifier."` + })(nil), + Request: handlers.AppLocalBrickRenameRequest{}, + CustomSuccessResponse: &CustomResponseDef{ + ContentType: "application/json", + DataStructure: bricks.LocalBrickRenameResult{}, + Description: "Successful response", + StatusCode: http.StatusOK, + }, + Description: "Rename a local brick. Changes the brick's ID and folder name derived from the new name. Only local bricks can be renamed.", + Summary: "Rename a local brick", + Tags: []Tag{ApplicationTag}, + PossibleErrors: []ErrorResponse{ + {StatusCode: http.StatusBadRequest, Reference: "#/components/responses/BadRequest"}, + {StatusCode: http.StatusNotFound, Reference: "#/components/responses/NotFound"}, + {StatusCode: http.StatusConflict, Reference: "#/components/responses/Conflict"}, + {StatusCode: http.StatusPreconditionFailed, Reference: "#/components/responses/PreconditionFailed"}, + {StatusCode: http.StatusInternalServerError, Reference: "#/components/responses/InternalServerError"}, + }, + }, { OperationId: "listLibraries", Method: http.MethodGet, diff --git a/internal/api/api.go b/internal/api/api.go index 0161db448..58e263614 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -100,6 +100,7 @@ func NewHTTPRouter( mux.Handle("PATCH /v1/apps/{appID}/bricks/{brickID}", handlers.HandleBrickUpdates(brickService, idProvider)) mux.Handle("DELETE /v1/apps/{appID}/bricks/{brickID}", handlers.HandleBrickDelete(brickService, idProvider)) mux.Handle("POST /v1/apps/{appID}/bricks", handlers.HandleAppLocalBrickCreate(idProvider)) + mux.Handle("POST /v1/apps/{appID}/bricks/{brickID}/rename", handlers.HandleAppLocalBrickRename(brickService, idProvider)) mux.Handle("GET /v1/docs/", http.StripPrefix("/v1/docs/", handlers.DocsServer(docsFS))) diff --git a/internal/api/docs/openapi.yaml b/internal/api/docs/openapi.yaml index ec019c347..c300c999f 100644 --- a/internal/api/docs/openapi.yaml +++ b/internal/api/docs/openapi.yaml @@ -270,6 +270,51 @@ paths: summary: Upsert a brick instance for an app tags: - Application + /v1/apps/{appID}/bricks/{brickID}/rename: + post: + description: Rename a local brick. Changes the brick's ID and folder name derived + from the new name. Only local bricks can be renamed. + operationId: renameAppLocalBrick + parameters: + - description: application identifier. + in: path + name: appID + required: true + schema: + description: application identifier. + type: string + - description: brick identifier. + in: path + name: brickID + required: true + schema: + description: brick identifier. + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AppLocalBrickRenameRequest' + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/LocalBrickRenameResult' + description: Successful response + "400": + $ref: '#/components/responses/BadRequest' + "404": + $ref: '#/components/responses/NotFound' + "409": + $ref: '#/components/responses/Conflict' + "412": + $ref: '#/components/responses/PreconditionFailed' + "500": + $ref: '#/components/responses/InternalServerError' + summary: Rename a local brick + tags: + - Application /v1/apps/{appID}/exposed-ports: get: description: Return all ports exposed by the given app. @@ -1515,6 +1560,11 @@ components: id: type: string type: object + AppLocalBrickRenameRequest: + properties: + name: + type: string + type: object AppPortResponse: properties: ports: @@ -1840,6 +1890,11 @@ components: pagination: $ref: '#/components/schemas/Pagination' type: object + LocalBrickRenameResult: + properties: + id: + type: string + type: object PackageType: description: Package type enum: diff --git a/internal/api/handlers/app_local_brick_rename.go b/internal/api/handlers/app_local_brick_rename.go new file mode 100644 index 000000000..9478a5bcf --- /dev/null +++ b/internal/api/handlers/app_local_brick_rename.go @@ -0,0 +1,93 @@ +// This file is part of arduino-app-cli. +// +// Copyright (C) Arduino s.r.l. and/or its affiliated companies +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// 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 handlers + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + + "github.com/arduino/arduino-app-cli/internal/api/models" + "github.com/arduino/arduino-app-cli/internal/orchestrator/app" + "github.com/arduino/arduino-app-cli/internal/orchestrator/bricks" + "github.com/arduino/arduino-app-cli/internal/render" +) + +type AppLocalBrickRenameRequest struct { + Name string `json:"name"` +} + +func HandleAppLocalBrickRename(brickService *bricks.Service, idProvider *app.IDProvider) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + appId, err := idProvider.IDFromBase64(r.PathValue("appID")) + if err != nil { + render.EncodeResponse(w, http.StatusPreconditionFailed, models.ErrorResponse{Details: "invalid app id"}) + return + } + + a, err := app.Load(appId.ToPath()) + if err != nil { + slog.Error("Unable to load the app", slog.String("error", err.Error()), slog.String("path", appId.String())) + render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "unable to find the app"}) + return + } + + oldID := r.PathValue("brickID") + if oldID == "" { + render.EncodeResponse(w, http.StatusBadRequest, models.ErrorResponse{Details: "brickID must be set"}) + return + } + + var req AppLocalBrickRenameRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + slog.Error("Failed to decode request body", slog.String("error", err.Error())) + render.EncodeResponse(w, http.StatusBadRequest, models.ErrorResponse{Details: "invalid request body"}) + return + } + if req.Name == "" { + render.EncodeResponse(w, http.StatusBadRequest, models.ErrorResponse{Details: "name is required"}) + return + } + + newID, err := generateBrickID(req.Name) + if err != nil { + render.EncodeResponse(w, http.StatusBadRequest, models.ErrorResponse{Details: err.Error()}) + return + } + + res, err := brickService.LocalBrickRename(&a, oldID, newID, req.Name) + if err != nil { + switch { + case errors.Is(err, bricks.ErrBrickNotFound): + render.EncodeResponse(w, http.StatusNotFound, models.ErrorResponse{Details: fmt.Sprintf("brick %q not found", oldID)}) + case errors.Is(err, bricks.ErrBrickNotLocal): + render.EncodeResponse(w, http.StatusBadRequest, models.ErrorResponse{Details: "only local bricks can be renamed"}) + case errors.Is(err, bricks.ErrBrickIDConflict): + render.EncodeResponse(w, http.StatusConflict, models.ErrorResponse{Details: fmt.Sprintf("a brick with id %q already exists", newID)}) + default: + slog.Error("Failed to rename local brick", slog.String("error", err.Error())) + render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "failed to rename local brick"}) + } + return + } + + render.EncodeResponse(w, http.StatusOK, res) + } +} diff --git a/internal/e2e/client/client.gen.go b/internal/e2e/client/client.gen.go index 199ef5698..9e49a423d 100644 --- a/internal/e2e/client/client.gen.go +++ b/internal/e2e/client/client.gen.go @@ -179,6 +179,11 @@ type AppLocalBrickCreateResponse struct { Id *string `json:"id,omitempty"` } +// AppLocalBrickRenameRequest defines model for AppLocalBrickRenameRequest. +type AppLocalBrickRenameRequest struct { + Name *string `json:"name,omitempty"` +} + // AppPortResponse defines model for AppPortResponse. type AppPortResponse struct { // Ports exposed port of the app @@ -376,6 +381,11 @@ type LibraryListResponse struct { Pagination *Pagination `json:"pagination,omitempty"` } +// LocalBrickRenameResult defines model for LocalBrickRenameResult. +type LocalBrickRenameResult struct { + Id *string `json:"id,omitempty"` +} + // PackageType Package type type PackageType string @@ -602,6 +612,9 @@ type UpdateAppBrickInstanceJSONRequestBody = BrickCreateUpdateRequest // UpsertAppBrickInstanceJSONRequestBody defines body for UpsertAppBrickInstance for application/json ContentType. type UpsertAppBrickInstanceJSONRequestBody = BrickCreateUpdateRequest +// RenameAppLocalBrickJSONRequestBody defines body for RenameAppLocalBrick for application/json ContentType. +type RenameAppLocalBrickJSONRequestBody = AppLocalBrickRenameRequest + // EditAppJSONRequestBody defines body for EditApp for application/json ContentType. type EditAppJSONRequestBody = EditRequest @@ -727,6 +740,11 @@ type ClientInterface interface { UpsertAppBrickInstance(ctx context.Context, appID string, brickID string, body UpsertAppBrickInstanceJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // RenameAppLocalBrickWithBody request with any body + RenameAppLocalBrickWithBody(ctx context.Context, appID string, brickID string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + RenameAppLocalBrick(ctx context.Context, appID string, brickID string, body RenameAppLocalBrickJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetAppPorts request GetAppPorts(ctx context.Context, appID string, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -1006,6 +1024,30 @@ func (c *Client) UpsertAppBrickInstance(ctx context.Context, appID string, brick return c.Client.Do(req) } +func (c *Client) RenameAppLocalBrickWithBody(ctx context.Context, appID string, brickID string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRenameAppLocalBrickRequestWithBody(c.Server, appID, brickID, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) RenameAppLocalBrick(ctx context.Context, appID string, brickID string, body RenameAppLocalBrickJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRenameAppLocalBrickRequest(c.Server, appID, brickID, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetAppPorts(ctx context.Context, appID string, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetAppPortsRequest(c.Server, appID) if err != nil { @@ -1681,7 +1723,7 @@ func NewCreateAppLocalBrickRequestWithBody(server string, appID string, contentT var pathParam0 string - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "appID", runtime.ParamLocationPath, appID) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "appID", appID, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -1901,6 +1943,60 @@ func NewUpsertAppBrickInstanceRequestWithBody(server string, appID string, brick return req, nil } +// NewRenameAppLocalBrickRequest calls the generic RenameAppLocalBrick builder with application/json body +func NewRenameAppLocalBrickRequest(server string, appID string, brickID string, body RenameAppLocalBrickJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewRenameAppLocalBrickRequestWithBody(server, appID, brickID, "application/json", bodyReader) +} + +// NewRenameAppLocalBrickRequestWithBody generates requests for RenameAppLocalBrick with any type of body +func NewRenameAppLocalBrickRequestWithBody(server string, appID string, brickID string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "appID", appID, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "brickID", brickID, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/v1/apps/%s/bricks/%s/rename", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewGetAppPortsRequest generates requests for GetAppPorts func NewGetAppPortsRequest(server string, appID string) (*http.Request, error) { var err error @@ -3323,6 +3419,11 @@ type ClientWithResponsesInterface interface { UpsertAppBrickInstanceWithResponse(ctx context.Context, appID string, brickID string, body UpsertAppBrickInstanceJSONRequestBody, reqEditors ...RequestEditorFn) (*UpsertAppBrickInstanceResp, error) + // RenameAppLocalBrickWithBodyWithResponse request with any body + RenameAppLocalBrickWithBodyWithResponse(ctx context.Context, appID string, brickID string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RenameAppLocalBrickResp, error) + + RenameAppLocalBrickWithResponse(ctx context.Context, appID string, brickID string, body RenameAppLocalBrickJSONRequestBody, reqEditors ...RequestEditorFn) (*RenameAppLocalBrickResp, error) + // GetAppPortsWithResponse request GetAppPortsWithResponse(ctx context.Context, appID string, reqEditors ...RequestEditorFn) (*GetAppPortsResp, error) @@ -3667,6 +3768,33 @@ func (r UpsertAppBrickInstanceResp) StatusCode() int { return 0 } +type RenameAppLocalBrickResp struct { + Body []byte + HTTPResponse *http.Response + JSON200 *LocalBrickRenameResult + JSON400 *BadRequest + JSON404 *NotFound + JSON409 *Conflict + JSON412 *PreconditionFailed + JSON500 *InternalServerError +} + +// Status returns HTTPResponse.Status +func (r RenameAppLocalBrickResp) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r RenameAppLocalBrickResp) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type GetAppPortsResp struct { Body []byte HTTPResponse *http.Response @@ -4517,6 +4645,23 @@ func (c *ClientWithResponses) UpsertAppBrickInstanceWithResponse(ctx context.Con return ParseUpsertAppBrickInstanceResp(rsp) } +// RenameAppLocalBrickWithBodyWithResponse request with arbitrary body returning *RenameAppLocalBrickResp +func (c *ClientWithResponses) RenameAppLocalBrickWithBodyWithResponse(ctx context.Context, appID string, brickID string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RenameAppLocalBrickResp, error) { + rsp, err := c.RenameAppLocalBrickWithBody(ctx, appID, brickID, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseRenameAppLocalBrickResp(rsp) +} + +func (c *ClientWithResponses) RenameAppLocalBrickWithResponse(ctx context.Context, appID string, brickID string, body RenameAppLocalBrickJSONRequestBody, reqEditors ...RequestEditorFn) (*RenameAppLocalBrickResp, error) { + rsp, err := c.RenameAppLocalBrick(ctx, appID, brickID, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseRenameAppLocalBrickResp(rsp) +} + // GetAppPortsWithResponse request returning *GetAppPortsResp func (c *ClientWithResponses) GetAppPortsWithResponse(ctx context.Context, appID string, reqEditors ...RequestEditorFn) (*GetAppPortsResp, error) { rsp, err := c.GetAppPorts(ctx, appID, reqEditors...) @@ -5236,6 +5381,67 @@ func ParseUpsertAppBrickInstanceResp(rsp *http.Response) (*UpsertAppBrickInstanc return response, nil } +// ParseRenameAppLocalBrickResp parses an HTTP response from a RenameAppLocalBrickWithResponse call +func ParseRenameAppLocalBrickResp(rsp *http.Response) (*RenameAppLocalBrickResp, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &RenameAppLocalBrickResp{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest LocalBrickRenameResult + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Conflict + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 412: + var dest PreconditionFailed + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON412 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalServerError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseGetAppPortsResp parses an HTTP response from a GetAppPortsWithResponse call func ParseGetAppPortsResp(rsp *http.Response) (*GetAppPortsResp, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/internal/orchestrator/bricks/bricks.go b/internal/orchestrator/bricks/bricks.go index e490b3d44..62dd9e4a1 100644 --- a/internal/orchestrator/bricks/bricks.go +++ b/internal/orchestrator/bricks/bricks.go @@ -22,11 +22,14 @@ import ( "errors" "fmt" "log/slog" + "os" "slices" "github.com/arduino/go-paths-helper" + yaml "github.com/goccy/go-yaml" "go.bug.st/f" + "github.com/arduino/arduino-app-cli/internal/fatomic" "github.com/arduino/arduino-app-cli/internal/orchestrator/app" "github.com/arduino/arduino-app-cli/internal/orchestrator/bricksindex" "github.com/arduino/arduino-app-cli/internal/orchestrator/config" @@ -36,6 +39,8 @@ import ( var ( ErrBrickNotFound = errors.New("brick not found") ErrCannotSaveBrick = errors.New("cannot save brick instance") + ErrBrickNotLocal = errors.New("brick is not a local brick") + ErrBrickIDConflict = errors.New("a brick with the new id already exists") ) type Service struct { @@ -446,3 +451,94 @@ func (s *Service) BrickDelete( } return nil } + +// LocalBrickRename renames a local brick by changing its ID, folder name, and display name. +// The newID is derived from the newName by the caller (handler layer). +func (s *Service) LocalBrickRename(appCurrent *app.ArduinoApp, oldID, newID, newName string) (_ LocalBrickRenameResult, _err error) { + if oldID == newID { + return LocalBrickRenameResult{}, fmt.Errorf("new brick id %q is the same as the current one", newID) + } + + localBrickIdx := slices.IndexFunc(appCurrent.LocalBricks, func(b bricksindex.Brick) bool { return b.ID == oldID }) + if localBrickIdx == -1 { + if _, found := s.bricksIndex.FindBrickByID(oldID); found { + return LocalBrickRenameResult{}, ErrBrickNotLocal + } + return LocalBrickRenameResult{}, ErrBrickNotFound + } + + if _, exist := s.bricksIndex.WithAppBricks(appCurrent.LocalBricks).FindBrickByID(newID); exist { + return LocalBrickRenameResult{}, ErrBrickIDConflict + } + + oldBrickPath := appCurrent.LocalBricks[localBrickIdx].FullPath + newBrickPath := appCurrent.LocalBricks[localBrickIdx].FullPath.Parent().Join(newID) + + if err := oldBrickPath.Rename(newBrickPath); err != nil { + return LocalBrickRenameResult{}, fmt.Errorf("cannot rename brick folder: %w", err) + } + // Rollback to old name in case of any error in the following steps. + defer func() { + if _err != nil { + _ = newBrickPath.Rename(oldBrickPath) + } + }() + + configPath := newBrickPath.Join("brick_config.yaml") + oldBrickConfigContent, err := os.ReadFile(configPath.String()) + if err != nil { + return LocalBrickRenameResult{}, fmt.Errorf("cannot read brick_config.yaml: %w", err) + } + if err := updateBrickConfig(configPath, newID, newName); err != nil { + return LocalBrickRenameResult{}, fmt.Errorf("cannot update brick_config.yaml: %w", err) + } + // Rollback brick_config.yaml in case of any error in the following steps. + defer func() { + if _err != nil { + _ = fatomic.WriteFile(configPath.String(), oldBrickConfigContent, os.FileMode(0644)) + } + }() + + if i := slices.IndexFunc(appCurrent.Descriptor.Bricks, func(b app.Brick) bool { return b.ID == oldID }); i != -1 { + appCurrent.Descriptor.Bricks[i].ID = newID + + // Rollback to old ID in case of any error in the following steps. + defer func() { + if _err != nil && i != -1 { + appCurrent.Descriptor.Bricks[i].ID = oldID + _ = appCurrent.Save() + } + }() + + if err := appCurrent.Save(); err != nil { + return LocalBrickRenameResult{}, fmt.Errorf("cannot save app: %w", err) + } + } + + return LocalBrickRenameResult{ID: newID}, nil +} + +func updateBrickConfig(brickConfigPath *paths.Path, newID, newName string) error { + content, err := os.ReadFile(brickConfigPath.String()) + if err != nil { + return fmt.Errorf("cannot read brick_config.yaml: %w", err) + } + + var brick bricksindex.Brick + if err := yaml.Unmarshal(content, &brick); err != nil { + return fmt.Errorf("cannot unmarshal brick_config.yaml: %w", err) + } + + brick.ID = newID + brick.Name = newName + + updated, err := yaml.Marshal(brick) + if err != nil { + return fmt.Errorf("cannot marshal brick_config.yaml: %w", err) + } + + if err := fatomic.WriteFile(brickConfigPath.String(), updated, os.FileMode(0644)); err != nil { + return fmt.Errorf("cannot write brick_config.yaml: %w", err) + } + return nil +} diff --git a/internal/orchestrator/bricks/bricks_test.go b/internal/orchestrator/bricks/bricks_test.go index 8426d07ad..a892f24c7 100644 --- a/internal/orchestrator/bricks/bricks_test.go +++ b/internal/orchestrator/bricks/bricks_test.go @@ -993,3 +993,99 @@ func TestAppBrickInstancesList(t *testing.T) { }) } } + +func TestLocalBrickRename(t *testing.T) { + const sourceApp = "testdata/dummy-app-with-local-brick" + const tempApp = "testdata/dummy-app-with-local-brick.temp" + + setup := func(t *testing.T) *app.ArduinoApp { + t.Helper() + require.NoError(t, paths.New(tempApp).RemoveAll()) + require.NoError(t, paths.New(sourceApp).CopyDirTo(paths.New(tempApp))) + t.Cleanup(func() { _ = paths.New(tempApp).RemoveAll() }) + a, err := app.Load(paths.New(tempApp)) + require.NoError(t, err) + return &a + } + + bricksIndex, err := bricksindex.Load(paths.New("testdata")) + require.NoError(t, err) + svc := NewService(nil, bricksIndex) + + t.Run("fails when old and new id are the same", func(t *testing.T) { + a := setup(t) + _, err := svc.LocalBrickRename(a, "my-local-brick", "my-local-brick", "My Local Brick") + require.Error(t, err) + require.Contains(t, err.Error(), "same as the current one") + }) + + t.Run("fails when brick is in global index (not local)", func(t *testing.T) { + a := setup(t) + _, err := svc.LocalBrickRename(a, "arduino:arduino_cloud", "arduino:arduino_cloud_v2", "Arduino Cloud V2") + require.ErrorIs(t, err, ErrBrickNotLocal) + }) + + t.Run("fails when brick is not found", func(t *testing.T) { + a := setup(t) + _, err := svc.LocalBrickRename(a, "non-existing-brick", "new-id", "New Name") + require.ErrorIs(t, err, ErrBrickNotFound) + }) + + t.Run("fails when new id conflicts with an existing builtin brick", func(t *testing.T) { + a := setup(t) + _, err := svc.LocalBrickRename(a, "my-local-brick", "arduino:arduino_cloud", "Arduino Cloud") + require.ErrorIs(t, err, ErrBrickIDConflict) + }) + + t.Run("fails when new id conflicts with an existing local brick", func(t *testing.T) { + a := setup(t) + _, err := svc.LocalBrickRename(a, "my-local-brick", "another-local-brick", "I want to change the name to another local brick") + require.ErrorIs(t, err, ErrBrickIDConflict) + }) + + t.Run("successfully renames the local brick", func(t *testing.T) { + a := setup(t) + + result, err := svc.LocalBrickRename(a, "my-local-brick", "my-renamed-brick", "My Renamed Brick") + require.NoError(t, err) + require.Equal(t, "my-renamed-brick", result.ID) + + require.False(t, a.FullPath.Join("bricks", "my-local-brick").Exist()) + require.True(t, a.FullPath.Join("bricks", "my-renamed-brick").Exist()) + + configPath := a.FullPath.Join("bricks", "my-renamed-brick", "brick_config.yaml").String() + raw, err := os.ReadFile(configPath) + require.NoError(t, err) + require.Contains(t, string(raw), "my-renamed-brick") + require.Contains(t, string(raw), "My Renamed Brick") + + appYamlPath := a.FullPath.Join("app.yaml").String() + appYamlRaw, err := os.ReadFile(appYamlPath) + require.NoError(t, err) + require.Contains(t, string(appYamlRaw), "my-renamed-brick") + require.NotContains(t, string(appYamlRaw), "my-local-brick") + }) + + t.Run("successfully renames a nested local brick", func(t *testing.T) { + a := setup(t) + + result, err := svc.LocalBrickRename(a, "nested-local-brick", "nested-renamed-brick", "Nested Renamed Brick") + require.NoError(t, err) + require.Equal(t, "nested-renamed-brick", result.ID) + + require.False(t, a.FullPath.Join("bricks", "nested", "nested-local-brick").Exist()) + require.True(t, a.FullPath.Join("bricks", "nested", "nested-renamed-brick").Exist()) + + configPath := a.FullPath.Join("bricks", "nested", "nested-renamed-brick", "brick_config.yaml").String() + raw, err := os.ReadFile(configPath) + require.NoError(t, err) + require.Contains(t, string(raw), "nested-renamed-brick") + require.Contains(t, string(raw), "Nested Renamed Brick") + + appYamlPath := a.FullPath.Join("app.yaml").String() + appYamlRaw, err := os.ReadFile(appYamlPath) + require.NoError(t, err) + require.Contains(t, string(appYamlRaw), "nested-renamed-brick") + require.NotContains(t, string(appYamlRaw), "nested-local-brick") + }) +} diff --git a/internal/orchestrator/bricks/testdata/dummy-app-with-local-brick/app.yaml b/internal/orchestrator/bricks/testdata/dummy-app-with-local-brick/app.yaml new file mode 100644 index 000000000..b21def680 --- /dev/null +++ b/internal/orchestrator/bricks/testdata/dummy-app-with-local-brick/app.yaml @@ -0,0 +1,6 @@ +name: App with local brick +description: An app with a local brick for testing +bricks: +- my-local-brick: +- another-local-brick: +- nested-local-brick: diff --git a/internal/orchestrator/bricks/testdata/dummy-app-with-local-brick/bricks/another-local-brick/brick_config.yaml b/internal/orchestrator/bricks/testdata/dummy-app-with-local-brick/bricks/another-local-brick/brick_config.yaml new file mode 100644 index 000000000..75b997a5e --- /dev/null +++ b/internal/orchestrator/bricks/testdata/dummy-app-with-local-brick/bricks/another-local-brick/brick_config.yaml @@ -0,0 +1,3 @@ +id: another-local-brick +name: Another Local Brick +description: "Another brick for testing" diff --git a/internal/orchestrator/bricks/testdata/dummy-app-with-local-brick/bricks/my-local-brick/brick_config.yaml b/internal/orchestrator/bricks/testdata/dummy-app-with-local-brick/bricks/my-local-brick/brick_config.yaml new file mode 100644 index 000000000..b62c182fe --- /dev/null +++ b/internal/orchestrator/bricks/testdata/dummy-app-with-local-brick/bricks/my-local-brick/brick_config.yaml @@ -0,0 +1,3 @@ +id: my-local-brick +name: My Local Brick +description: "A local brick for testing" diff --git a/internal/orchestrator/bricks/testdata/dummy-app-with-local-brick/bricks/nested/nested-local-brick/brick_config.yaml b/internal/orchestrator/bricks/testdata/dummy-app-with-local-brick/bricks/nested/nested-local-brick/brick_config.yaml new file mode 100644 index 000000000..c28880515 --- /dev/null +++ b/internal/orchestrator/bricks/testdata/dummy-app-with-local-brick/bricks/nested/nested-local-brick/brick_config.yaml @@ -0,0 +1,3 @@ +id: nested-local-brick +name: Nested Local Brick +description: "A nested local brick for testing" diff --git a/internal/orchestrator/bricks/testdata/dummy-app-with-local-brick/python/main.py b/internal/orchestrator/bricks/testdata/dummy-app-with-local-brick/python/main.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/internal/orchestrator/bricks/testdata/dummy-app-with-local-brick/python/main.py @@ -0,0 +1 @@ + diff --git a/internal/orchestrator/bricks/types.go b/internal/orchestrator/bricks/types.go index deaa9fadb..af5aec026 100644 --- a/internal/orchestrator/bricks/types.go +++ b/internal/orchestrator/bricks/types.go @@ -76,6 +76,10 @@ type AppReference struct { Icon string `json:"icon"` } +type LocalBrickRenameResult struct { + ID string `json:"id"` +} + type BrickDetailsResult struct { ID string `json:"id"` Name string `json:"name"`