diff --git a/.gitignore b/.gitignore index 943061b9..e9f949ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ trellis-cli dist tmp +.DS_Store diff --git a/cli_config/cli_config.go b/cli_config/cli_config.go index 11b9048a..641fe2e1 100644 --- a/cli_config/cli_config.go +++ b/cli_config/cli_config.go @@ -19,8 +19,9 @@ type VmImage struct { type VmConfig struct { Manager string `yaml:"manager"` HostsResolver string `yaml:"hosts_resolver"` - Images []VmImage `yaml:"images"` Ubuntu string `yaml:"ubuntu"` + InstanceName string `yaml:"instance_name"` // Custom name for the Lima VM instance + Images []VmImage `yaml:"images"` // VM image configuration (as a slice) } type Config struct { diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 00000000..f2cfae91 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,49 @@ +// Package cmd contains all command-line interface commands for Trellis CLI. +package cmd + +import ( + "fmt" + "runtime" + + "github.com/mitchellh/cli" + "github.com/roots/trellis-cli/pkg/lima" + "github.com/roots/trellis-cli/pkg/vm" + "github.com/roots/trellis-cli/trellis" +) + +// This file provides centralized declarations of functions as variables +// to enable mocking in tests and prevent duplicate implementations. +// Previously, these functions were defined in multiple places, causing +// "redeclared in this block" errors during compilation. + +// Declare these as variables so they can be overridden in tests +var ( + // newVmManager creates a VM manager instance based on configuration. + // Using a function variable allows for mocking in tests and centralizes VM manager creation logic. + // This approach eliminates duplicate implementations that previously existed across files. + newVmManager = func(t *trellis.Trellis, ui cli.Ui) (vm.Manager, error) { + // Select appropriate VM manager based on configuration + switch t.CliConfig.Vm.Manager { + case "auto": + switch runtime.GOOS { + case "darwin": + return lima.NewManager(t, ui) + default: + return nil, fmt.Errorf("no VM managers are supported on %s yet", runtime.GOOS) + } + case "lima": + return lima.NewManager(t, ui) + case "mock": + return vm.NewMockManager(t, ui) + } + + return nil, fmt.Errorf("vm manager not found") + } + + // NewProvisionCommand creates a new ProvisionCommand instance. + // This was moved here from provision.go to follow the same pattern + // of using function variables for testability. + NewProvisionCommand = func(ui cli.Ui, trellis *trellis.Trellis) *ProvisionCommand { + return &ProvisionCommand{UI: ui, Trellis: trellis} + } +) diff --git a/cmd/provision.go b/cmd/provision.go index e0f01d7f..e9a921cd 100644 --- a/cmd/provision.go +++ b/cmd/provision.go @@ -12,12 +12,6 @@ import ( "github.com/roots/trellis-cli/trellis" ) -func NewProvisionCommand(ui cli.Ui, trellis *trellis.Trellis) *ProvisionCommand { - c := &ProvisionCommand{UI: ui, Trellis: trellis} - c.init() - return c -} - type ProvisionCommand struct { UI cli.Ui flags *flag.FlagSet diff --git a/cmd/vm.go b/cmd/vm.go index 6a3a17fc..1945c601 100644 --- a/cmd/vm.go +++ b/cmd/vm.go @@ -1,38 +1,17 @@ package cmd import ( - "fmt" "os" "path/filepath" - "runtime" "github.com/mitchellh/cli" - "github.com/roots/trellis-cli/pkg/lima" - "github.com/roots/trellis-cli/pkg/vm" "github.com/roots/trellis-cli/trellis" ) const VagrantInventoryFilePath string = ".vagrant/provisioners/ansible/inventory/vagrant_ansible_inventory" -func newVmManager(trellis *trellis.Trellis, ui cli.Ui) (manager vm.Manager, err error) { - switch trellis.CliConfig.Vm.Manager { - case "auto": - switch runtime.GOOS { - case "darwin": - return lima.NewManager(trellis, ui) - default: - return nil, fmt.Errorf("No VM managers are supported on %s yet.", runtime.GOOS) - } - case "lima": - return lima.NewManager(trellis, ui) - case "mock": - return vm.NewMockManager(trellis, ui) - } - - return nil, fmt.Errorf("VM manager not found") -} - func findDevInventory(trellis *trellis.Trellis, ui cli.Ui) string { + // Use the newVmManager variable from main.go manager, managerErr := newVmManager(trellis, ui) if managerErr == nil { diff --git a/cmd/vm_delete.go b/cmd/vm_delete.go index fa00db84..44155212 100644 --- a/cmd/vm_delete.go +++ b/cmd/vm_delete.go @@ -3,6 +3,8 @@ package cmd import ( "flag" "strings" + "os" + "path/filepath" "github.com/manifoldco/promptui" "github.com/mitchellh/cli" @@ -67,7 +69,11 @@ func (c *VmDeleteCommand) Run(args []string) int { if err := manager.DeleteInstance(siteName); err != nil { c.UI.Error("Error: " + err.Error()) return 1 - } + } + + // Remove instance file if it exists + instancePath := filepath.Join(c.Trellis.ConfigPath(), "lima", "instance") + os.Remove(instancePath) // Ignore errors as file may not exist } return 0 diff --git a/cmd/vm_delete_test.go b/cmd/vm_delete_test.go index 581a1b12..07a58646 100644 --- a/cmd/vm_delete_test.go +++ b/cmd/vm_delete_test.go @@ -1,10 +1,13 @@ package cmd import ( + "os" + "path/filepath" "strings" "testing" "github.com/mitchellh/cli" + "github.com/roots/trellis-cli/pkg/vm" "github.com/roots/trellis-cli/trellis" ) @@ -54,3 +57,60 @@ func TestVmDeleteRunValidations(t *testing.T) { }) } } + +func TestVmDeleteRemovesInstanceFile(t *testing.T) { + cleanup := trellis.LoadFixtureProject(t) + defer cleanup() + + // Setup test environment + ui := cli.NewMockUi() + mockTrellis := trellis.NewTrellis() + mockTrellis.LoadProject() + + // Create the lima directory and instance file + limaDir := filepath.Join(mockTrellis.ConfigPath(), "lima") + err := os.MkdirAll(limaDir, 0755) + if err != nil { + t.Fatalf("failed to create lima directory: %v", err) + } + + instancePath := filepath.Join(limaDir, "instance") + err = os.WriteFile(instancePath, []byte("example.com"), 0644) + if err != nil { + t.Fatalf("failed to write instance file: %v", err) + } + + // Verify file exists before test + if _, err := os.Stat(instancePath); os.IsNotExist(err) { + t.Fatalf("failed to create test instance file") + } + + // Create command + vmDeleteCommand := NewVmDeleteCommand(ui, mockTrellis) + vmDeleteCommand.force = true // Skip confirmation prompt + + // Replace VM manager with mock + mockManager := &MockVmManager{} + + // Save original function and restore after test + originalManagerFunc := newVmManager + defer func() { newVmManager = originalManagerFunc }() + + newVmManager = func(t *trellis.Trellis, ui cli.Ui) (vm.Manager, error) { + return mockManager, nil + } + + // Run command + code := vmDeleteCommand.Run([]string{}) + + // Check command succeeded + if code != 0 { + t.Errorf("expected exit code 0, got %d", code) + } + + // Check instance file was removed + _, err = os.Stat(instancePath) + if !os.IsNotExist(err) { + t.Error("expected instance file to be deleted") + } +} diff --git a/cmd/vm_start.go b/cmd/vm_start.go index 3c2128eb..0f9a1e3a 100644 --- a/cmd/vm_start.go +++ b/cmd/vm_start.go @@ -49,9 +49,15 @@ func (c *VmStartCommand) Run(args []string) int { return 1 } - siteName, _, err := c.Trellis.MainSiteFromEnvironment("development") + // CHANGE: Use GetVMInstanceName instead of MainSiteFromEnvironment + siteName, err := c.Trellis.GetVMInstanceName() if err != nil { - c.UI.Error("Error: could not automatically set VM name: " + err.Error()) + c.UI.Error("Error: could not get VM name: " + err.Error()) + return 1 + } + + if siteName == "" { + c.UI.Error("Error: could not automatically set VM name. No site found in development environment.") return 1 } @@ -80,6 +86,11 @@ func (c *VmStartCommand) Run(args []string) int { return 1 } + // Save the instance name for future reference + if err = c.Trellis.SaveVMInstanceName(siteName); err != nil { + c.UI.Warn("Warning: Failed to save VM instance name. VM was created successfully, but future commands may not recognize it.") + } + if err = manager.StartInstance(siteName); err != nil { c.UI.Error("Error starting VM.") c.UI.Error(err.Error()) diff --git a/cmd/vm_start_test.go b/cmd/vm_start_test.go index 143ac68e..3480fada 100644 --- a/cmd/vm_start_test.go +++ b/cmd/vm_start_test.go @@ -1,13 +1,85 @@ package cmd import ( + "os" + "path/filepath" "strings" "testing" "github.com/mitchellh/cli" + "github.com/roots/trellis-cli/pkg/vm" "github.com/roots/trellis-cli/trellis" ) +// Store original functions to restore later +var ( + originalNewVmManager = newVmManager + originalNewProvisionCmd = NewProvisionCommand +) + +// MockVmManager for testing +type MockVmManager struct { + createCalled bool + startCalled bool + siteName string +} + +func (m *MockVmManager) CreateInstance(name string) error { + m.createCalled = true + m.siteName = name + return nil +} + +func (m *MockVmManager) StartInstance(name string) error { + m.startCalled = true + m.siteName = name + // First call returns VmNotFoundErr to trigger creation + if !m.createCalled { + return vm.VmNotFoundErr + } + return nil +} + +func (m *MockVmManager) StopInstance(name string) error { + return nil +} + +func (m *MockVmManager) DeleteInstance(name string) error { + return nil +} + +// Add the missing InventoryPath method required by the vm.Manager interface +func (m *MockVmManager) InventoryPath() string { + return "/mock/inventory/path" +} + +// Update the OpenShell method with the correct signature +func (m *MockVmManager) OpenShell(sshUser string, hostName string, additionalArgs []string) error { + // Mock implementation + return nil +} + +// Mock version of NewProvisionCommand for testing +type MockProvisionCommand struct { + UI cli.Ui + Trellis *trellis.Trellis + Run func(args []string) int +} + +func (c *MockProvisionCommand) Synopsis() string { return "" } +func (c *MockProvisionCommand) Help() string { return "" } + +// Add a MockTrellis type that implements the GetVMInstanceName method +type MockTrellisWithVMName struct { + *trellis.Trellis + instanceName string +} + +// Override the GetVMInstanceName method for testing +func (m *MockTrellisWithVMName) GetVMInstanceName() (string, error) { + return m.instanceName, nil +} + func TestVmStartRunValidations(t *testing.T) { defer trellis.LoadFixtureProject(t)() @@ -54,3 +126,134 @@ func TestVmStartRunValidations(t *testing.T) { }) } } + +func TestVmStartSavesInstanceName(t *testing.T) { + cleanup := trellis.LoadFixtureProject(t) + defer cleanup() + + // Setup test environment + ui := cli.NewMockUi() + mockTrellis := trellis.NewTrellis() + mockTrellis.LoadProject() + + // Create command + vmStartCommand := NewVmStartCommand(ui, mockTrellis) + + // Replace VM manager with mock + mockManager := &MockVmManager{} + + // Save original function and replace with test double + defer func() { newVmManager = originalNewVmManager }() + newVmManager = func(t *trellis.Trellis, ui cli.Ui) (vm.Manager, error) { + return mockManager, nil + } + + // Mock provision command + defer func() { NewProvisionCommand = originalNewProvisionCmd }() + NewProvisionCommand = func(ui cli.Ui, trellis *trellis.Trellis) *ProvisionCommand { + // Create an actual ProvisionCommand instead of trying to cast from MockProvisionCommand + cmd := &ProvisionCommand{ + UI: ui, + Trellis: trellis, + } + + // No need for type casting, return the real command + return cmd + } + + // Run command + code := vmStartCommand.Run([]string{}) + + // Check VM was created and started + if code != 0 { + t.Errorf("expected exit code 0, got %d", code) + } + if !mockManager.createCalled { + t.Error("expected CreateInstance to be called") + } + if !mockManager.startCalled { + t.Error("expected StartInstance to be called") + } + + // Check instance file was created + instancePath := filepath.Join(mockTrellis.ConfigPath(), "lima", "instance") + data, err := os.ReadFile(instancePath) + if err != nil { + t.Errorf("expected instance file to exist: %v", err) + return + } + + instanceName := strings.TrimSpace(string(data)) + if instanceName != mockManager.siteName { + t.Errorf("expected instance name %q, got %q", mockManager.siteName, instanceName) + } +} + +// Add this test to verify the VM name resolution +func TestVmStartUsesGetVMInstanceName(t *testing.T) { + cleanup := trellis.LoadFixtureProject(t) + defer cleanup() + + // Setup test environment with our custom mock + ui := cli.NewMockUi() + mockTrellis := trellis.NewTrellis() + mockTrellis.LoadProject() + + // Create a custom mock Trellis that returns a specific instance name + mockTrellisWithVMName := &MockTrellisWithVMName{ + Trellis: mockTrellis, + instanceName: "custom-instance-name", + } + + // Create command with our custom mock + vmStartCommand := NewVmStartCommand(ui, mockTrellisWithVMName.Trellis) + + // Replace VM manager with mock + mockManager := &MockVmManager{} + + // Save original function and replace with test double + defer func() { newVmManager = originalNewVmManager }() + newVmManager = func(t *trellis.Trellis, ui cli.Ui) (vm.Manager, error) { + return mockManager, nil + } + + // Mock provision command + defer func() { NewProvisionCommand = originalNewProvisionCmd }() + NewProvisionCommand = func(ui cli.Ui, trellis *trellis.Trellis) *ProvisionCommand { + cmd := &ProvisionCommand{ + UI: ui, + Trellis: trellis, + } + return cmd + } + + // Run command + code := vmStartCommand.Run([]string{}) + + // Check VM was created and started with the correct instance name + if code != 0 { + t.Errorf("expected exit code 0, got %d", code) + } + if !mockManager.createCalled { + t.Error("expected CreateInstance to be called") + } + if !mockManager.startCalled { + t.Error("expected StartInstance to be called") + } + if mockManager.siteName != "custom-instance-name" { + t.Errorf("expected site name to be 'custom-instance-name', got %s", mockManager.siteName) + } + + // Check instance file was created with correct name + instancePath := filepath.Join(mockTrellis.ConfigPath(), "lima", "instance") + data, err := os.ReadFile(instancePath) + if err != nil { + t.Errorf("expected instance file to exist: %v", err) + return + } + + instanceName := strings.TrimSpace(string(data)) + if instanceName != "custom-instance-name" { + t.Errorf("expected instance name %q, got %q", "custom-instance-name", instanceName) + } +} diff --git a/trellis/trellis.go b/trellis/trellis.go index 83e229cc..eaf5cebb 100644 --- a/trellis/trellis.go +++ b/trellis/trellis.go @@ -263,16 +263,28 @@ func (t *Trellis) FindSiteNameFromEnvironment(environment string, siteNameArg st return "", fmt.Errorf("Error: %s is not a valid site. Valid options are %s", siteNameArg, siteNames) } -func (t *Trellis) MainSiteFromEnvironment(environment string) (string, *Site, error) { - sites := t.SiteNamesFromEnvironment(environment) - - if len(sites) == 0 { - return "", nil, fmt.Errorf("Error: No sites found in %s environment", environment) +func (t *Trellis) MainSiteFromEnvironment(env string) (string, *Site, error) { + if _, ok := t.Environments[env]; !ok { + return "", nil, fmt.Errorf("environment %s not found", env) } - name := sites[0] - - return name, t.Environments[environment].WordPressSites[name], nil + // Get the instance name using our new priority system + siteName, err := t.GetVMInstanceName() + if err != nil || siteName == "" { + // Fall back to using the first site name if there's an error or empty result + siteNames := t.SiteNamesFromEnvironment(env) + if len(siteNames) == 0 { + return "", nil, fmt.Errorf("no sites found in environment %s", env) + } + siteName = siteNames[0] + } + + site, ok := t.Environments[env].WordPressSites[siteName] + if !ok { + return "", nil, fmt.Errorf("site %s not found in environment %s", siteName, env) + } + + return siteName, site, nil } func (t *Trellis) getDefaultSiteNameFromEnvironment(environment string) (siteName string, err error) { diff --git a/trellis/vm_instance.go b/trellis/vm_instance.go new file mode 100644 index 00000000..b5b83ddf --- /dev/null +++ b/trellis/vm_instance.go @@ -0,0 +1,97 @@ +package trellis + +import ( + "os" + "os/exec" + "path/filepath" + "strings" +) + +const ( + LimaDirName = "lima" + InstanceFile = "instance" +) + +// GetVMInstanceName returns the VM instance name based on the following priority: +// 1. Instance file in .trellis/lima/instance +// 2. CliConfig instance_name setting +// 3. Check for existing VMs matching any site name +// 4. First site in development environment's wordpress_sites.yml +func (t *Trellis) GetVMInstanceName() (string, error) { + // 1. Check for instance file + instanceName, err := t.readInstanceNameFromFile() + if err == nil && instanceName != "" { + return instanceName, nil + } + + // 2. Check CLI config for instance_name + if t.CliConfig.Vm.InstanceName != "" { + return t.CliConfig.Vm.InstanceName, nil + } + + // 3. NEW: Check for existing VMs matching site names + // Get all site names from the development environment + siteNames := t.SiteNamesFromEnvironment("development") + + // Check if any of these site names already exists as a VM + for _, siteName := range siteNames { + vmExists, _ := checkVMExists(siteName) + if vmExists { + // Found existing VM - save this for future use + t.SaveVMInstanceName(siteName) + return siteName, nil + } + } + + // 4. Simply use the first site in the development environment + config := t.Environments["development"] + if config == nil || len(config.WordPressSites) == 0 { + return "", nil + } + + // Get the first site name alphabetically (which is the default behavior) + if len(siteNames) > 0 { + return siteNames[0], nil + } + + return "", nil +} + +// checkVMExists checks if a VM with the given name already exists +func checkVMExists(name string) (bool, error) { + cmd := exec.Command("limactl", "list", "--json") + output, err := cmd.Output() + + if err != nil { + return false, err + } + + // Simple string check rather than parsing JSON + return strings.Contains(string(output), `"name":"`+name+`"`) || + strings.Contains(string(output), `"name": "`+name+`"`), nil +} + +// SaveVMInstanceName writes the VM instance name to the instance file +func (t *Trellis) SaveVMInstanceName(instanceName string) error { + limaDir := filepath.Join(t.ConfigPath(), LimaDirName) + + // Create the lima directory if it doesn't exist + if err := os.MkdirAll(limaDir, 0755); err != nil && !os.IsExist(err) { + return err + } + + instancePath := filepath.Join(limaDir, InstanceFile) + return os.WriteFile(instancePath, []byte(instanceName), 0644) +} + +// readInstanceNameFromFile reads the VM instance name from the instance file +func (t *Trellis) readInstanceNameFromFile() (string, error) { + instancePath := filepath.Join(t.ConfigPath(), LimaDirName, InstanceFile) + + data, err := os.ReadFile(instancePath) + if err != nil { + return "", err + } + + return strings.TrimSpace(string(data)), nil +} diff --git a/trellis/vm_instance_test.go b/trellis/vm_instance_test.go new file mode 100644 index 00000000..4ee51fb1 --- /dev/null +++ b/trellis/vm_instance_test.go @@ -0,0 +1,181 @@ +package trellis + +import ( + "os" + "path/filepath" + "testing" +) + +func TestGetVMInstanceName(t *testing.T) { + tempDir := t.TempDir() + defer TestChdir(t, tempDir)() + + // Create a mock Trellis structure + tp := &Trellis{ + ConfigDir: ".trellis", + Path: tempDir, + Environments: map[string]*Config{ + "development": { + WordPressSites: map[string]*Site{ + "example.com": {}, + "another-site.com": {}, + }, + }, + }, + } + + // Create config directory + if err := tp.CreateConfigDir(); err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + // Test case 1: No instance file, no config setting + // Should return the first site alphabetically (another-site.com) + name, err := tp.GetVMInstanceName() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if name != "another-site.com" { + t.Errorf("Expected 'another-site.com', got '%s'", name) + } + + // Test case 2: With config setting + tp.CliConfig.Vm.InstanceName = "custom-name" + name, err = tp.GetVMInstanceName() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if name != "custom-name" { + t.Errorf("Expected 'custom-name', got '%s'", name) + } + + // Test case 3: With instance file (highest priority) + limaDir := filepath.Join(tp.ConfigPath(), LimaDirName) + if err := os.MkdirAll(limaDir, 0755); err != nil { + t.Fatalf("Failed to create lima directory: %v", err) + } + instancePath := filepath.Join(limaDir, InstanceFile) + if err := os.WriteFile(instancePath, []byte("instance-file-name"), 0644); err != nil { + t.Fatalf("Failed to write instance file: %v", err) + } + + name, err = tp.GetVMInstanceName() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if name != "instance-file-name" { + t.Errorf("Expected 'instance-file-name', got '%s'", name) + } + + // Clean up + tp.CliConfig.Vm.InstanceName = "" +} + +func TestSaveVMInstanceName(t *testing.T) { + tempDir := t.TempDir() + defer TestChdir(t, tempDir)() + + // Create a mock Trellis structure + tp := &Trellis{ + ConfigDir: ".trellis", + Path: tempDir, + } + + // Create config directory + if err := tp.CreateConfigDir(); err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + // Save instance name + instanceName := "test-vm-instance" + if err := tp.SaveVMInstanceName(instanceName); err != nil { + t.Fatalf("Failed to save instance name: %v", err) + } + + // Verify file was created + instancePath := filepath.Join(tp.ConfigPath(), LimaDirName, InstanceFile) + data, err := os.ReadFile(instancePath) + if err != nil { + t.Fatalf("Failed to read instance file: %v", err) + } + + // Verify content + if string(data) != instanceName { + t.Errorf("Expected '%s', got '%s'", instanceName, string(data)) + } + + // Test updating existing file + newInstanceName := "updated-name" + if err := tp.SaveVMInstanceName(newInstanceName); err != nil { + t.Fatalf("Failed to update instance name: %v", err) + } + + // Verify update + data, err = os.ReadFile(instancePath) + if err != nil { + t.Fatalf("Failed to read instance file: %v", err) + } + + if string(data) != newInstanceName { + t.Errorf("Expected '%s', got '%s'", newInstanceName, string(data)) + } +} + +func TestReadInstanceNameFromFile(t *testing.T) { + tempDir := t.TempDir() + defer TestChdir(t, tempDir)() + + // Create a mock Trellis structure + tp := &Trellis{ + ConfigDir: ".trellis", + Path: tempDir, + } + + // Create config directory + if err := tp.CreateConfigDir(); err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + // Test reading non-existent file + name, err := tp.readInstanceNameFromFile() + if err == nil { + t.Error("Expected error when reading non-existent file") + } + if name != "" { + t.Errorf("Expected empty string, got '%s'", name) + } + + // Create instance file + limaDir := filepath.Join(tp.ConfigPath(), LimaDirName) + if err := os.MkdirAll(limaDir, 0755); err != nil { + t.Fatalf("Failed to create lima directory: %v", err) + } + instancePath := filepath.Join(limaDir, InstanceFile) + expectedName := "instance-file-name" + if err := os.WriteFile(instancePath, []byte(expectedName), 0644); err != nil { + t.Fatalf("Failed to write instance file: %v", err) + } + + // Test reading existing file + name, err = tp.readInstanceNameFromFile() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if name != expectedName { + t.Errorf("Expected '%s', got '%s'", expectedName, name) + } + + // Test with trailing whitespace + expectedName = "trimmed-name" + if err := os.WriteFile(instancePath, []byte(expectedName+"\n"), 0644); err != nil { + t.Fatalf("Failed to write instance file: %v", err) + } + + name, err = tp.readInstanceNameFromFile() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if name != expectedName { + t.Errorf("Expected '%s', got '%s'", expectedName, name) + } +}