diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3bb8b5..3b1b28c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,4 +44,4 @@ jobs: run: go mod download - name: Run tests - run: go test -v ./... \ No newline at end of file + run: make test \ No newline at end of file diff --git a/Makefile b/Makefile index 37a9f8d..490c4e4 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ TANTIVY_VERSION := v1.0.4 TANTIVY_LIB_PATH ?= dist/tantivy CGO_LDFLAGS := -L$(TANTIVY_LIB_PATH) -GOLANGCI_LINT_VERSION := v2.2.1 +GOLANGCI_LINT_VERSION := v2.7.2 ##@ Build @@ -126,9 +126,9 @@ install-linter: ## Install golangci-lint @go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) @echo "golangci-lint installed successfully" -test: ## Run tests +test: download-tantivy ## Run tests @echo "Running tests..." - @go test github.com/anyproto/anytype-cli/... + @CGO_ENABLED=1 CGO_LDFLAGS="$(CGO_LDFLAGS)" go test github.com/anyproto/anytype-cli/... @echo "Tests completed" ##@ Cleanup diff --git a/cmd/serve/serve.go b/cmd/serve/serve.go index f66aef3..9321901 100644 --- a/cmd/serve/serve.go +++ b/cmd/serve/serve.go @@ -4,10 +4,13 @@ import ( "github.com/kardianos/service" "github.com/spf13/cobra" + "github.com/anyproto/anytype-cli/core/config" "github.com/anyproto/anytype-cli/core/output" "github.com/anyproto/anytype-cli/core/serviceprogram" ) +var listenAddress string + func NewServeCmd() *cobra.Command { cmd := &cobra.Command{ Use: "serve", @@ -17,6 +20,8 @@ func NewServeCmd() *cobra.Command { RunE: runServer, } + cmd.Flags().StringVar(&listenAddress, "listen-address", config.DefaultAPIAddress, "API listen address in `host:port` format") + return cmd } @@ -27,7 +32,7 @@ func runServer(cmd *cobra.Command, args []string) error { Description: "Anytype", } - prg := serviceprogram.New() + prg := serviceprogram.New(listenAddress) s, err := service.New(prg, svcConfig) if err != nil { diff --git a/cmd/serve/serve_test.go b/cmd/serve/serve_test.go new file mode 100644 index 0000000..a08123f --- /dev/null +++ b/cmd/serve/serve_test.go @@ -0,0 +1,53 @@ +package serve + +import ( + "testing" + + "github.com/anyproto/anytype-cli/core/config" +) + +func TestNewServeCmd(t *testing.T) { + cmd := NewServeCmd() + + if cmd.Use != "serve" { + t.Errorf("cmd.Use = %v, want serve", cmd.Use) + } + + if len(cmd.Aliases) != 1 || cmd.Aliases[0] != "start" { + t.Errorf("cmd.Aliases = %v, want [start]", cmd.Aliases) + } +} + +func TestServeCmd_ListenAddressFlag(t *testing.T) { + cmd := NewServeCmd() + + flag := cmd.Flag("listen-address") + if flag == nil { + t.Fatal("listen-address flag not found") + return + } + + if flag.DefValue != config.DefaultAPIAddress { + t.Errorf("listen-address default = %v, want %v", flag.DefValue, config.DefaultAPIAddress) + } + + if flag.Usage != "API listen address in `host:port` format" { + t.Errorf("listen-address usage = %v, want 'API listen address in `host:port` format'", flag.Usage) + } +} + +func TestServeCmd_ListenAddressFlagCustomValue(t *testing.T) { + cmd := NewServeCmd() + + customAddr := "0.0.0.0:8080" + cmd.SetArgs([]string{"--listen-address", customAddr}) + + if err := cmd.ParseFlags([]string{"--listen-address", customAddr}); err != nil { + t.Fatalf("Failed to parse flags: %v", err) + } + + flag := cmd.Flag("listen-address") + if flag.Value.String() != customAddr { + t.Errorf("listen-address value = %v, want %v", flag.Value.String(), customAddr) + } +} diff --git a/cmd/service/install/install.go b/cmd/service/install/install.go new file mode 100644 index 0000000..8a5b8f4 --- /dev/null +++ b/cmd/service/install/install.go @@ -0,0 +1,45 @@ +package install + +import ( + "github.com/spf13/cobra" + + "github.com/anyproto/anytype-cli/core/config" + "github.com/anyproto/anytype-cli/core/output" + "github.com/anyproto/anytype-cli/core/serviceprogram" +) + +func NewInstallCmd() *cobra.Command { + var listenAddress string + + cmd := &cobra.Command{ + Use: "install", + Short: "Install as a user service", + RunE: func(cmd *cobra.Command, args []string) error { + s, err := serviceprogram.GetServiceWithAddress(listenAddress) + if err != nil { + return output.Error("Failed to create service: %w", err) + } + + err = s.Install() + if err != nil { + return output.Error("Failed to install service: %w", err) + } + + output.Success("anytype service installed successfully") + if listenAddress != config.DefaultAPIAddress { + output.Info("API will listen on %s", listenAddress) + } + output.Print("\nTo manage the service:") + output.Print(" Start: anytype service start") + output.Print(" Stop: anytype service stop") + output.Print(" Restart: anytype service restart") + output.Print(" Status: anytype service status") + + return nil + }, + } + + cmd.Flags().StringVar(&listenAddress, "listen-address", config.DefaultAPIAddress, "API listen address in `host:port` format") + + return cmd +} diff --git a/cmd/service/install/install_test.go b/cmd/service/install/install_test.go new file mode 100644 index 0000000..5239133 --- /dev/null +++ b/cmd/service/install/install_test.go @@ -0,0 +1,52 @@ +package install + +import ( + "testing" + + "github.com/anyproto/anytype-cli/core/config" +) + +func TestNewInstallCmd(t *testing.T) { + cmd := NewInstallCmd() + + if cmd.Use != "install" { + t.Errorf("cmd.Use = %v, want install", cmd.Use) + } + + if cmd.Short != "Install as a user service" { + t.Errorf("cmd.Short = %v, want 'Install as a user service'", cmd.Short) + } +} + +func TestInstallCmd_ListenAddressFlag(t *testing.T) { + cmd := NewInstallCmd() + + flag := cmd.Flag("listen-address") + if flag == nil { + t.Fatal("listen-address flag not found") + return + } + + if flag.DefValue != config.DefaultAPIAddress { + t.Errorf("listen-address default = %v, want %v", flag.DefValue, config.DefaultAPIAddress) + } + + if flag.Usage != "API listen address in `host:port` format" { + t.Errorf("listen-address usage = %v, want 'API listen address in `host:port` format'", flag.Usage) + } +} + +func TestInstallCmd_ListenAddressFlagCustomValue(t *testing.T) { + cmd := NewInstallCmd() + + customAddr := "0.0.0.0:9000" + + if err := cmd.ParseFlags([]string{"--listen-address", customAddr}); err != nil { + t.Fatalf("Failed to parse flags: %v", err) + } + + flag := cmd.Flag("listen-address") + if flag.Value.String() != customAddr { + t.Errorf("listen-address value = %v, want %v", flag.Value.String(), customAddr) + } +} diff --git a/cmd/service/restart/restart.go b/cmd/service/restart/restart.go new file mode 100644 index 0000000..1aa0df8 --- /dev/null +++ b/cmd/service/restart/restart.go @@ -0,0 +1,39 @@ +package restart + +import ( + "errors" + + "github.com/kardianos/service" + "github.com/spf13/cobra" + + "github.com/anyproto/anytype-cli/core/output" + "github.com/anyproto/anytype-cli/core/serviceprogram" +) + +func NewRestartCmd() *cobra.Command { + return &cobra.Command{ + Use: "restart", + Short: "Restart the service", + RunE: func(cmd *cobra.Command, args []string) error { + s, err := serviceprogram.GetService() + if err != nil { + return output.Error("Failed to create service: %w", err) + } + + _, err = s.Status() + if err != nil && errors.Is(err, service.ErrNotInstalled) { + output.Warning("anytype service is not installed") + output.Info("Run 'anytype service install' to install it first") + return nil + } + + err = s.Restart() + if err != nil { + return output.Error("Failed to restart service: %w", err) + } + + output.Success("anytype service restarted") + return nil + }, + } +} diff --git a/cmd/service/service.go b/cmd/service/service.go index 2f27cdd..8c9f0b9 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -1,215 +1,29 @@ package service import ( - "errors" - "os" - - "github.com/kardianos/service" "github.com/spf13/cobra" - "github.com/anyproto/anytype-cli/core/config" - "github.com/anyproto/anytype-cli/core/output" - "github.com/anyproto/anytype-cli/core/serviceprogram" + serviceInstallCmd "github.com/anyproto/anytype-cli/cmd/service/install" + serviceRestartCmd "github.com/anyproto/anytype-cli/cmd/service/restart" + serviceStartCmd "github.com/anyproto/anytype-cli/cmd/service/start" + serviceStatusCmd "github.com/anyproto/anytype-cli/cmd/service/status" + serviceStopCmd "github.com/anyproto/anytype-cli/cmd/service/stop" + serviceUninstallCmd "github.com/anyproto/anytype-cli/cmd/service/uninstall" ) -// getService creates a service instance with our standard configuration -func getService() (service.Service, error) { - options := service.KeyValue{ - "UserService": true, - } - - logDir := config.GetLogsDir() - if logDir != "" { - if err := os.MkdirAll(logDir, 0755); err == nil { - options["LogDirectory"] = logDir - } - } - - svcConfig := &service.Config{ - Name: "anytype", - DisplayName: "Anytype", - Description: "Anytype", - Arguments: []string{"serve"}, - Option: options, - } - - prg := serviceprogram.New() - return service.New(prg, svcConfig) -} - func NewServiceCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "service", + Use: "service ", Short: "Manage anytype as a user service", Long: "Install, uninstall, start, stop, and check status of anytype running as a user service.", } - cmd.AddCommand( - &cobra.Command{ - Use: "install", - Short: "Install as a user service", - RunE: installService, - }, - &cobra.Command{ - Use: "uninstall", - Short: "Uninstall the user service", - RunE: uninstallService, - }, - &cobra.Command{ - Use: "start", - Short: "Start the service", - RunE: startService, - }, - &cobra.Command{ - Use: "stop", - Short: "Stop the service", - RunE: stopService, - }, - &cobra.Command{ - Use: "restart", - Short: "Restart the service", - RunE: restartService, - }, - &cobra.Command{ - Use: "status", - Short: "Check service status", - RunE: statusService, - }, - ) + cmd.AddCommand(serviceInstallCmd.NewInstallCmd()) + cmd.AddCommand(serviceUninstallCmd.NewUninstallCmd()) + cmd.AddCommand(serviceStartCmd.NewStartCmd()) + cmd.AddCommand(serviceStopCmd.NewStopCmd()) + cmd.AddCommand(serviceRestartCmd.NewRestartCmd()) + cmd.AddCommand(serviceStatusCmd.NewStatusCmd()) return cmd } - -func installService(cmd *cobra.Command, args []string) error { - s, err := getService() - if err != nil { - return output.Error("Failed to create service: %w", err) - } - - err = s.Install() - if err != nil { - return output.Error("Failed to install service: %w", err) - } - - output.Success("anytype service installed successfully") - output.Print("\nTo manage the service:") - output.Print(" Start: anytype service start") - output.Print(" Stop: anytype service stop") - output.Print(" Restart: anytype service restart") - output.Print(" Status: anytype service status") - - return nil -} - -func uninstallService(cmd *cobra.Command, args []string) error { - s, err := getService() - if err != nil { - return output.Error("Failed to create service: %w", err) - } - - err = s.Uninstall() - if err != nil { - return output.Error("Failed to uninstall service: %w", err) - } - - output.Success("anytype service uninstalled successfully") - return nil -} - -func startService(cmd *cobra.Command, args []string) error { - s, err := getService() - if err != nil { - return output.Error("Failed to create service: %w", err) - } - - // Check if service is installed first - _, err = s.Status() - if err != nil && errors.Is(err, service.ErrNotInstalled) { - output.Warning("anytype service is not installed") - output.Info("Run 'anytype service install' to install it first") - return nil - } - - err = s.Start() - if err != nil { - return output.Error("Failed to start service: %w", err) - } - - output.Success("anytype service started") - return nil -} - -func stopService(cmd *cobra.Command, args []string) error { - s, err := getService() - if err != nil { - return output.Error("Failed to create service: %w", err) - } - - // Check if service is installed first - _, err = s.Status() - if err != nil && errors.Is(err, service.ErrNotInstalled) { - output.Warning("anytype service is not installed") - output.Info("Run 'anytype service install' to install it first") - return nil - } - - err = s.Stop() - if err != nil { - return output.Error("Failed to stop service: %w", err) - } - - output.Success("anytype service stopped") - return nil -} - -func restartService(cmd *cobra.Command, args []string) error { - s, err := getService() - if err != nil { - return output.Error("Failed to create service: %w", err) - } - - // Check if service is installed first - _, err = s.Status() - if err != nil && errors.Is(err, service.ErrNotInstalled) { - output.Warning("anytype service is not installed") - output.Info("Run 'anytype service install' to install it first") - return nil - } - - err = s.Restart() - if err != nil { - return output.Error("Failed to restart service: %w", err) - } - - output.Success("anytype service restarted") - return nil -} - -func statusService(cmd *cobra.Command, args []string) error { - s, err := getService() - if err != nil { - return output.Error("Failed to create service: %w", err) - } - - status, err := s.Status() - if err != nil { - if errors.Is(err, service.ErrNotInstalled) { - output.Info("anytype service is not installed") - output.Info("Run 'anytype service install' to install it") - return nil - } - return output.Error("Failed to get service status: %w", err) - } - - switch status { - case service.StatusRunning: - output.Success("anytype service is running") - case service.StatusStopped: - output.Info("anytype service is stopped") - output.Info("Run 'anytype service start' to start it") - default: - output.Info("anytype service status: %v", status) - } - - return nil -} diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go new file mode 100644 index 0000000..f3d2abd --- /dev/null +++ b/cmd/service/service_test.go @@ -0,0 +1,60 @@ +package service + +import ( + "testing" +) + +func TestNewServiceCmd(t *testing.T) { + cmd := NewServiceCmd() + + if cmd.Use != "service " { + t.Errorf("cmd.Use = %v, want 'service '", cmd.Use) + } + + if cmd.Short != "Manage anytype as a user service" { + t.Errorf("cmd.Short = %v, want 'Manage anytype as a user service'", cmd.Short) + } +} + +func TestServiceCmd_HasAllSubcommands(t *testing.T) { + cmd := NewServiceCmd() + + expectedSubcommands := []string{ + "install", + "uninstall", + "start", + "stop", + "restart", + "status", + } + + subcommands := cmd.Commands() + if len(subcommands) != len(expectedSubcommands) { + t.Errorf("service has %d subcommands, want %d", len(subcommands), len(expectedSubcommands)) + } + + subcommandMap := make(map[string]bool) + for _, sub := range subcommands { + subcommandMap[sub.Use] = true + } + + for _, expected := range expectedSubcommands { + if !subcommandMap[expected] { + t.Errorf("subcommand %q not found", expected) + } + } +} + +func TestServiceCmd_InstallHasListenAddressFlag(t *testing.T) { + cmd := NewServiceCmd() + + installCmd, _, err := cmd.Find([]string{"install"}) + if err != nil { + t.Fatalf("Failed to find install subcommand: %v", err) + } + + flag := installCmd.Flag("listen-address") + if flag == nil { + t.Fatal("install subcommand should have listen-address flag") + } +} diff --git a/cmd/service/start/start.go b/cmd/service/start/start.go new file mode 100644 index 0000000..ea3c8d7 --- /dev/null +++ b/cmd/service/start/start.go @@ -0,0 +1,39 @@ +package start + +import ( + "errors" + + "github.com/kardianos/service" + "github.com/spf13/cobra" + + "github.com/anyproto/anytype-cli/core/output" + "github.com/anyproto/anytype-cli/core/serviceprogram" +) + +func NewStartCmd() *cobra.Command { + return &cobra.Command{ + Use: "start", + Short: "Start the service", + RunE: func(cmd *cobra.Command, args []string) error { + s, err := serviceprogram.GetService() + if err != nil { + return output.Error("Failed to create service: %w", err) + } + + _, err = s.Status() + if err != nil && errors.Is(err, service.ErrNotInstalled) { + output.Warning("anytype service is not installed") + output.Info("Run 'anytype service install' to install it first") + return nil + } + + err = s.Start() + if err != nil { + return output.Error("Failed to start service: %w", err) + } + + output.Success("anytype service started") + return nil + }, + } +} diff --git a/cmd/service/status/status.go b/cmd/service/status/status.go new file mode 100644 index 0000000..dda7457 --- /dev/null +++ b/cmd/service/status/status.go @@ -0,0 +1,46 @@ +package status + +import ( + "errors" + + "github.com/kardianos/service" + "github.com/spf13/cobra" + + "github.com/anyproto/anytype-cli/core/output" + "github.com/anyproto/anytype-cli/core/serviceprogram" +) + +func NewStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Check service status", + RunE: func(cmd *cobra.Command, args []string) error { + s, err := serviceprogram.GetService() + if err != nil { + return output.Error("Failed to create service: %w", err) + } + + status, err := s.Status() + if err != nil { + if errors.Is(err, service.ErrNotInstalled) { + output.Info("anytype service is not installed") + output.Info("Run 'anytype service install' to install it") + return nil + } + return output.Error("Failed to get service status: %w", err) + } + + switch status { + case service.StatusRunning: + output.Success("anytype service is running") + case service.StatusStopped: + output.Info("anytype service is stopped") + output.Info("Run 'anytype service start' to start it") + default: + output.Info("anytype service status: %v", status) + } + + return nil + }, + } +} diff --git a/cmd/service/stop/stop.go b/cmd/service/stop/stop.go new file mode 100644 index 0000000..29cbf6e --- /dev/null +++ b/cmd/service/stop/stop.go @@ -0,0 +1,39 @@ +package stop + +import ( + "errors" + + "github.com/kardianos/service" + "github.com/spf13/cobra" + + "github.com/anyproto/anytype-cli/core/output" + "github.com/anyproto/anytype-cli/core/serviceprogram" +) + +func NewStopCmd() *cobra.Command { + return &cobra.Command{ + Use: "stop", + Short: "Stop the service", + RunE: func(cmd *cobra.Command, args []string) error { + s, err := serviceprogram.GetService() + if err != nil { + return output.Error("Failed to create service: %w", err) + } + + _, err = s.Status() + if err != nil && errors.Is(err, service.ErrNotInstalled) { + output.Warning("anytype service is not installed") + output.Info("Run 'anytype service install' to install it first") + return nil + } + + err = s.Stop() + if err != nil { + return output.Error("Failed to stop service: %w", err) + } + + output.Success("anytype service stopped") + return nil + }, + } +} diff --git a/cmd/service/uninstall/uninstall.go b/cmd/service/uninstall/uninstall.go new file mode 100644 index 0000000..8dd84a4 --- /dev/null +++ b/cmd/service/uninstall/uninstall.go @@ -0,0 +1,29 @@ +package uninstall + +import ( + "github.com/spf13/cobra" + + "github.com/anyproto/anytype-cli/core/output" + "github.com/anyproto/anytype-cli/core/serviceprogram" +) + +func NewUninstallCmd() *cobra.Command { + return &cobra.Command{ + Use: "uninstall", + Short: "Uninstall the user service", + RunE: func(cmd *cobra.Command, args []string) error { + s, err := serviceprogram.GetService() + if err != nil { + return output.Error("Failed to create service: %w", err) + } + + err = s.Uninstall() + if err != nil { + return output.Error("Failed to uninstall service: %w", err) + } + + output.Success("anytype service uninstalled successfully") + return nil + }, + } +} diff --git a/core/serviceprogram/serviceprogram.go b/core/serviceprogram/serviceprogram.go index a8fd159..acc357c 100644 --- a/core/serviceprogram/serviceprogram.go +++ b/core/serviceprogram/serviceprogram.go @@ -3,6 +3,7 @@ package serviceprogram import ( "context" "fmt" + "os" "sync" "time" @@ -14,18 +15,60 @@ import ( "github.com/anyproto/anytype-cli/core/output" ) +// GetService creates a service instance with default configuration. +func GetService() (service.Service, error) { + return GetServiceWithAddress("") +} + +// GetServiceWithAddress creates a service instance with a custom API listen address. +func GetServiceWithAddress(apiAddr string) (service.Service, error) { + options := service.KeyValue{ + "UserService": true, + } + + logDir := config.GetLogsDir() + if logDir != "" { + if err := os.MkdirAll(logDir, 0755); err == nil { + options["LogDirectory"] = logDir + } + } + + effectiveAddr := apiAddr + if effectiveAddr == "" { + effectiveAddr = config.DefaultAPIAddress + } + + args := []string{"serve"} + if effectiveAddr != config.DefaultAPIAddress { + args = append(args, "--listen-address", effectiveAddr) + } + + svcConfig := &service.Config{ + Name: "anytype", + DisplayName: "Anytype", + Description: "Anytype", + Arguments: args, + Option: options, + } + + prg := New(effectiveAddr) + return service.New(prg, svcConfig) +} + type Program struct { - server *grpcserver.Server - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup - startErr error - startCh chan struct{} + server *grpcserver.Server + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + startErr error + startCh chan struct{} + apiListenAddr string } -func New() *Program { +func New(apiListenAddr string) *Program { return &Program{ - startCh: make(chan struct{}), + startCh: make(chan struct{}), + apiListenAddr: apiListenAddr, } } @@ -99,7 +142,7 @@ func (p *Program) attemptAutoLogin() { maxRetries := 3 for i := 0; i < maxRetries; i++ { - if err := core.Authenticate(accountKey, "", ""); err != nil { + if err := core.Authenticate(accountKey, "", p.apiListenAddr); err != nil { if i < maxRetries-1 { time.Sleep(2 * time.Second) continue diff --git a/core/serviceprogram/serviceprogram_test.go b/core/serviceprogram/serviceprogram_test.go new file mode 100644 index 0000000..1d7f317 --- /dev/null +++ b/core/serviceprogram/serviceprogram_test.go @@ -0,0 +1,94 @@ +package serviceprogram + +import ( + "testing" + + "github.com/anyproto/anytype-cli/core/config" +) + +func TestNew(t *testing.T) { + tests := []struct { + name string + apiListenAddr string + wantAddr string + }{ + { + name: "with default address", + apiListenAddr: config.DefaultAPIAddress, + wantAddr: config.DefaultAPIAddress, + }, + { + name: "with custom address", + apiListenAddr: "0.0.0.0:8080", + wantAddr: "0.0.0.0:8080", + }, + { + name: "with empty address", + apiListenAddr: "", + wantAddr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prg := New(tt.apiListenAddr) + + if prg == nil { + t.Fatal("New() returned nil") + return + } + + if prg.apiListenAddr != tt.wantAddr { + t.Errorf("apiListenAddr = %v, want %v", prg.apiListenAddr, tt.wantAddr) + } + + if prg.startCh == nil { + t.Error("startCh should be initialized") + } + }) + } +} + +func TestGetService(t *testing.T) { + svc, err := GetService() + if err != nil { + t.Fatalf("GetService() error = %v", err) + } + + if svc == nil { + t.Fatal("GetService() returned nil service") + } +} + +func TestGetServiceWithAddress(t *testing.T) { + tests := []struct { + name string + apiAddr string + }{ + { + name: "with empty address uses default", + apiAddr: "", + }, + { + name: "with default address", + apiAddr: config.DefaultAPIAddress, + }, + { + name: "with custom address", + apiAddr: "0.0.0.0:9999", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, err := GetServiceWithAddress(tt.apiAddr) + if err != nil { + t.Fatalf("GetServiceWithAddress() error = %v", err) + } + + if svc == nil { + t.Fatal("GetServiceWithAddress() returned nil service") + } + }) + } +}