Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 33 additions & 39 deletions contexts/contexts.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ func (m *Manager) renameEntry(store *config.Store, t *renameTarget) (RenameResul
return RenameResult{}, fmt.Errorf("%w: %q", t.existsErr, t.newName)
}

if err := m.renameAndRetarget(store, t.path, t.affected, t.rename, t.retarget); err != nil {
if err := m.renameAndRetarget(store, t); err != nil {
return RenameResult{}, err
}

Expand Down Expand Up @@ -270,25 +270,19 @@ func validateCredential(cred *config.Credential) error {
// every file that owns an affected context. Each file is written exactly once:
// a file that both defines the entry and holds referencing contexts gets both
// mutations applied before it is persisted.
func (m *Manager) renameAndRetarget(
store *config.Store,
entryPath string,
affected []string,
rename func(*config.Config),
retarget func(*config.Config),
) error {
func (m *Manager) renameAndRetarget(store *config.Store, t *renameTarget) error {
// Collect every file that needs a write.
paths := map[string]struct{}{entryPath: {}}
for _, name := range affected {
paths := map[string]struct{}{t.path: {}}
for _, name := range t.affected {
paths[store.PathForContext(name)] = struct{}{}
}

for path := range paths {
cfg := store.FileConfig(path)
if path == entryPath {
rename(&cfg)
if path == t.path {
t.rename(&cfg)
}
retarget(&cfg)
t.retarget(&cfg)

if err := m.Writer.Write(path, &cfg); err != nil {
return err
Expand All @@ -303,17 +297,17 @@ func (m *Manager) renameAndRetarget(
// updated there too.
func (m *Manager) RenameContext(store *config.Store, oldName, newName string) error {
if _, found := store.Config.ContextByName(oldName); !found {
return fmt.Errorf("cannot rename context %q, it does not exist", oldName)
return fmt.Errorf("%w: %q", ErrContextNotFound, oldName)
}

if _, found := store.Config.ContextByName(newName); found {
return fmt.Errorf("cannot rename context %q, context %q already exists", oldName, newName)
return fmt.Errorf("%w: %q", ErrContextExists, newName)
}

path := store.PathForContext(oldName)
cfg := store.FileConfig(path)
if renamed := cfg.RenameContext(oldName, newName); !renamed {
return fmt.Errorf("cannot rename context %q, it does not exist in %q", oldName, path)
return fmt.Errorf("%w: %q in %q", ErrContextNotFound, oldName, path)
}

if cfg.CurrentContext == oldName {
Expand Down Expand Up @@ -361,13 +355,13 @@ type RenameResult struct {
// it was the active context.
func (m *Manager) DeleteContext(store *config.Store, name string) (DeleteResult, error) {
if _, found := store.Config.ContextByName(name); !found {
return DeleteResult{}, fmt.Errorf("context %q not found", name)
return DeleteResult{}, fmt.Errorf("%w: %q", ErrContextNotFound, name)
}

path := store.PathForContext(name)
cfg := store.FileConfig(path)
if deleted := cfg.DeleteContext(name); !deleted {
return DeleteResult{}, fmt.Errorf("context %q not found in %q", name, path)
return DeleteResult{}, fmt.Errorf("%w: %q in %q", ErrContextNotFound, name, path)
}

if err := m.Writer.Write(path, &cfg); err != nil {
Expand All @@ -383,12 +377,12 @@ func (m *Manager) DeleteContext(store *config.Store, name string) (DeleteResult,
func (m *Manager) DeleteTenant(store *config.Store, name string) (DeleteResult, error) {
_, found := store.Config.TenantByName(name)
return m.deleteEntry(store, &entryTarget{
kind: "tenant",
name: name,
found: found,
path: store.PathForTenant(name),
deleteX: func(cfg *config.Config) bool { return cfg.DeleteTenant(name) },
orphans: store.Config.ContextsReferencingTenant(name),
notFound: ErrTenantNotFound,
name: name,
found: found,
path: store.PathForTenant(name),
remove: func(cfg *config.Config) bool { return cfg.DeleteTenant(name) },
orphans: store.Config.ContextsReferencingTenant(name),
})
}

Expand All @@ -398,35 +392,35 @@ func (m *Manager) DeleteTenant(store *config.Store, name string) (DeleteResult,
func (m *Manager) DeleteCredential(store *config.Store, name string) (DeleteResult, error) {
_, found := store.Config.CredentialByName(name)
return m.deleteEntry(store, &entryTarget{
kind: "credential",
name: name,
found: found,
path: store.PathForCredential(name),
deleteX: func(cfg *config.Config) bool { return cfg.DeleteCredential(name) },
orphans: store.Config.ContextsReferencingCredential(name),
notFound: ErrCredentialNotFound,
name: name,
found: found,
path: store.PathForCredential(name),
remove: func(cfg *config.Config) bool { return cfg.DeleteCredential(name) },
orphans: store.Config.ContextsReferencingCredential(name),
})
}

// entryTarget describes a non-context entry to delete.
type entryTarget struct {
deleteX func(cfg *config.Config) bool
kind string
name string
path string
orphans []string
found bool
notFound error
remove func(cfg *config.Config) bool
name string
path string
orphans []string
found bool
}

// deleteEntry removes a tenant or credential entry. Contexts use DeleteContext
// directly because they additionally report whether the active context changed.
func (m *Manager) deleteEntry(store *config.Store, t *entryTarget) (DeleteResult, error) {
if !t.found {
return DeleteResult{}, fmt.Errorf("%s %q not found", t.kind, t.name)
return DeleteResult{}, fmt.Errorf("%w: %q", t.notFound, t.name)
}

cfg := store.FileConfig(t.path)
if deleted := t.deleteX(&cfg); !deleted {
return DeleteResult{}, fmt.Errorf("%s %q not found in %q", t.kind, t.name, t.path)
if removed := t.remove(&cfg); !removed {
return DeleteResult{}, fmt.Errorf("%w: %q in %q", t.notFound, t.name, t.path)
}

if err := m.Writer.Write(t.path, &cfg); err != nil {
Expand Down
42 changes: 26 additions & 16 deletions contexts/contexts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,8 @@ func TestManager_RenameContext_UpdatesCurrent(t *testing.T) {
func TestManager_RenameContext_Errors(t *testing.T) {
writeConfig(t, baseConfig())

require.Error(t, New().RenameContext(loadStore(t), "ghost", "new"))
require.Error(t, New().RenameContext(loadStore(t), devContext, prodContext))
require.ErrorIs(t, New().RenameContext(loadStore(t), "ghost", "new"), ErrContextNotFound)
require.ErrorIs(t, New().RenameContext(loadStore(t), devContext, prodContext), ErrContextExists)
}

func TestManager_DeleteContext(t *testing.T) {
Expand Down Expand Up @@ -236,7 +236,7 @@ func TestManager_DeleteContext_NotFound(t *testing.T) {
store := loadStore(t)

_, err := New().DeleteContext(store, "ghost")
require.Error(t, err)
require.ErrorIs(t, err, ErrContextNotFound)
}

func TestManager_DeleteTenant(t *testing.T) {
Expand All @@ -252,14 +252,6 @@ func TestManager_DeleteTenant(t *testing.T) {
assert.False(t, found)
}

func TestManager_DeleteTenant_NotFound(t *testing.T) {
writeConfig(t, baseConfig())
store := loadStore(t)

_, err := New().DeleteTenant(store, "ghost")
require.Error(t, err)
}

func TestManager_DeleteCredential(t *testing.T) {
path := writeConfig(t, baseConfig())
store := loadStore(t)
Expand All @@ -273,10 +265,28 @@ func TestManager_DeleteCredential(t *testing.T) {
assert.False(t, found)
}

func TestManager_DeleteCredential_NotFound(t *testing.T) {
writeConfig(t, baseConfig())
store := loadStore(t)
func TestManager_Delete_NotFound(t *testing.T) {
tests := []struct {
wantErr error
delete func(m *Manager, store *config.Store) error
name string
}{
{
name: "tenant",
delete: func(m *Manager, s *config.Store) error { _, err := m.DeleteTenant(s, "ghost"); return err },
wantErr: ErrTenantNotFound,
},
{
name: "credential",
delete: func(m *Manager, s *config.Store) error { _, err := m.DeleteCredential(s, "ghost"); return err },
wantErr: ErrCredentialNotFound,
},
}

_, err := New().DeleteCredential(store, "ghost")
require.Error(t, err)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
writeConfig(t, baseConfig())
require.ErrorIs(t, tt.delete(New(), loadStore(t)), tt.wantErr)
})
}
}
7 changes: 1 addition & 6 deletions tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,11 @@ type App struct {

// NewApp builds the root model wired with a production contexts.Manager.
func NewApp(store *config.Store, mode state.Mode) *App {
return newApp(store, mode, contexts.New())
}

// newApp builds the root model with an explicit manager, for test injection.
func newApp(store *config.Store, mode state.Mode, manager tabs.Manager) *App {
s := state.New(store, mode)
return &App{
state: s,
splash: splash.New(s),
tabs: tabs.New(s, manager),
tabs: tabs.New(s, contexts.New()),
}
}

Expand Down
25 changes: 17 additions & 8 deletions tui/form/form.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
package form

import (
"errors"
"fmt"
"strings"

"github.com/charmbracelet/bubbles/key"
Expand Down Expand Up @@ -38,10 +40,13 @@ type Submitted struct {
// Canceled is emitted when the user aborts the form.
type Canceled struct{}

// ErrRequired is returned when a required field is left empty.
var ErrRequired = errors.New("field is required")

// Model is a focusable multi-field form.
type Model struct {
title string
err string
err error
keys formKeys
fields []Field
inputs []textinput.Model
Expand Down Expand Up @@ -102,6 +107,10 @@ func (m *Model) Values() map[string]string {
return values
}

// Err returns the current validation error, or nil when the form is valid. It
// is set when a submit is blocked and cleared on navigation.
func (m *Model) Err() error { return m.err }

// Update handles navigation, editing, submission, and cancellation. It returns
// a Submitted or Canceled tea.Cmd when the form terminates.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { //nolint:gocritic // Bubble Tea value-receiver idiom; refactor tracked separately
Expand Down Expand Up @@ -138,13 +147,13 @@ func (m Model) trySubmit() (Model, tea.Cmd) { //nolint:gocritic // Bubble Tea va
for i, f := range m.fields {
value := strings.TrimSpace(m.inputs[i].Value())
if f.Required && value == "" {
m.failField(i, f.Label+" is required")
m.failField(i, fmt.Errorf("%w: %s", ErrRequired, f.Label))
return m, nil
}

if f.Validate != nil {
if err := f.Validate(value); err != nil {
m.failField(i, err.Error())
m.failField(i, err)
return m, nil
}
}
Expand All @@ -157,15 +166,15 @@ func (m Model) trySubmit() (Model, tea.Cmd) { //nolint:gocritic // Bubble Tea va

// failField records a validation error and focuses the offending field when it
// is editable.
func (m *Model) failField(index int, msg string) {
m.err = msg
func (m *Model) failField(index int, err error) {
m.err = err
if !m.fields[index].ReadOnly {
m.focusOn(index)
}
}

func (m *Model) focusDelta(delta int) {
m.err = ""
m.err = nil
m.focusOn(m.nextEditable(m.focus, delta))
}

Expand Down Expand Up @@ -216,8 +225,8 @@ func (m Model) View() string { //nolint:gocritic // Bubble Tea value-receiver id
rows = append(rows, row)
}

if m.err != "" {
rows = append(rows, "", styles.ErrorStyle.Render(m.err))
if m.err != nil {
rows = append(rows, "", styles.ErrorStyle.Render(m.err.Error()))
}

rows = append(rows, "", styles.HelpStyle.Render("tab/↑↓ move · enter submit · esc cancel"))
Expand Down
8 changes: 4 additions & 4 deletions tui/form/form_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func TestModel_RequiredFieldBlocksSubmit(t *testing.T) {

next, cmd := m.Update(keyMsg("enter"))
assert.Nil(t, cmd, "submit must be blocked")
assert.NotEmpty(t, next.err, "an inline error must be shown")
require.ErrorIs(t, next.Err(), ErrRequired)
assert.Equal(t, 0, next.focus, "focus returns to the invalid field")
}

Expand All @@ -93,7 +93,7 @@ func TestModel_ValidatorBlocksSubmit(t *testing.T) {

next, cmd := m.Update(keyMsg("enter"))
assert.Nil(t, cmd)
assert.Equal(t, sentinel.Error(), next.err)
require.ErrorIs(t, next.Err(), sentinel)
}

func TestModel_Navigation(t *testing.T) {
Expand All @@ -114,10 +114,10 @@ func TestModel_NavigationClearsError(t *testing.T) {
m := twoFieldForm()

m, _ = m.Update(keyMsg("enter")) // triggers required error
require.NotEmpty(t, m.err)
require.Error(t, m.Err())

m, _ = m.Update(keyMsg("tab"))
assert.Empty(t, m.err, "moving focus clears the error")
assert.NoError(t, m.Err(), "moving focus clears the error")
}

func TestModel_ReadOnlyFieldSkipped(t *testing.T) {
Expand Down
Loading
Loading