diff --git a/cmd/cloudstic/cmd_tui_profile_form.go b/cmd/cloudstic/cmd_tui_profile_form.go index df00795..2975ebe 100644 --- a/cmd/cloudstic/cmd_tui_profile_form.go +++ b/cmd/cloudstic/cmd_tui_profile_form.go @@ -70,6 +70,7 @@ func newTUIProfileModal(profilesFile, existingName string, editing bool) (*tuiPr return nil, fmt.Errorf("no store references available; create one first") } moveDefaultToFront(storeOptions, existing.Store) + source := newTUIProfileSource(existing.Source) m := &tuiProfileModal{ profilesFile: profilesFile, @@ -85,24 +86,23 @@ func newTUIProfileModal(profilesFile, existingName string, editing bool) (*tuiPr CancelLabel: "Cancel", Fields: []tui.ModalField{ {Key: "name", Label: "Name", Kind: tui.ModalFieldText, Value: existingName, Required: true, Disabled: editing}, - {Key: "source_type", Label: "Source Type", Kind: tui.ModalFieldSelect, Value: "local", Options: append([]string{}, tuiSourceTypes...), Required: true}, - {Key: "source_value", Label: "Path", Kind: tui.ModalFieldText, Required: true}, + {Key: "source_type", Label: "Source Type", Kind: tui.ModalFieldSelect, Value: source.Type, Options: append([]string{}, tuiSourceTypes...), Required: true}, + {Key: "source_value", Label: source.DetailLabel(), Kind: tui.ModalFieldText, Value: source.Value, Required: source.DetailRequired()}, {Key: "store", Label: "Store", Kind: tui.ModalFieldSelect, Value: firstNonEmpty(existing.Store, firstOption(storeOptions)), Options: storeOptions, Required: true}, {Key: "auth", Label: "Auth", Kind: tui.ModalFieldSelect, Value: existing.AuthRef}, }, }, } - m.loadSource(existing.Source) - m.syncAuthField() + m.rebuildDerivedFields() m.selectFirstEditableField() return m, nil } func (m *tuiProfileModal) View() tui.Modal { - m.syncSourceFieldMetadata() + source := m.currentSource() view := m.modal - view.Subtitle = profileModalSubtitle(m) - view.Message = sourceFieldExamples(m) + view.Subtitle = profileModalSubtitle(source, m.cfg) + view.Message = sourceFieldExamples(m.selectedFieldKey(), source) return view } @@ -125,7 +125,13 @@ func (m *tuiProfileModal) Handle(input tuiModalInput) (bool, string, error) { case tuiModalInputEnter: name, err := m.submit() if err != nil { - m.modal.ErrorField, m.modal.Error = modalValidationError(err) + if fieldErr, ok := err.(*tuiFieldError); ok { + m.modal.ErrorField = fieldErr.Field + m.modal.Error = fieldErr.Message + } else { + m.modal.ErrorField = "" + m.modal.Error = err.Error() + } return false, "", nil } return true, name, nil @@ -182,10 +188,7 @@ func (m *tuiProfileModal) cycleField(delta int) { field.Value = field.Options[idx] m.clearError() if field.Key == "source_type" { - m.syncSourceFieldMetadata() - } - if field.Key == "source_type" || field.Key == "source_value" { - m.syncAuthField() + m.rebuildDerivedFields() } } @@ -196,8 +199,8 @@ func (m *tuiProfileModal) appendField(text string) { } field.Value += text m.clearError() - if field.Key == "source_type" || field.Key == "source_value" { - m.syncAuthField() + if field.Key == "source_value" { + m.rebuildDerivedFields() } } @@ -209,17 +212,22 @@ func (m *tuiProfileModal) backspaceField() { runes := []rune(field.Value) field.Value = string(runes[:len(runes)-1]) m.clearError() - if field.Key == "source_type" || field.Key == "source_value" { - m.syncAuthField() + if field.Key == "source_value" { + m.rebuildDerivedFields() } } -func (m *tuiProfileModal) syncAuthField() { +func (m *tuiProfileModal) rebuildDerivedFields() { + m.updateSourceFieldMetadata() + m.updateAuthField() +} + +func (m *tuiProfileModal) updateAuthField() { field := m.fieldByKey("auth") if field == nil { return } - provider := profileProviderFromSource(m.composedSource()) + provider := m.currentSource().Provider() if provider == "" { field.Disabled = true field.Options = nil @@ -243,49 +251,49 @@ func (m *tuiProfileModal) syncAuthField() { } func (m *tuiProfileModal) submit() (string, error) { - name := strings.TrimSpace(m.fieldValue("name")) + name := m.textFieldValue("name") if !m.editing { if name == "" { - return "", modalFieldError("name", "profile name is required") + return "", fieldError("name", "profile name is required") } if err := validateRefName("profile", name); err != nil { - return "", modalFieldError("name", err.Error()) + return "", fieldError("name", err.Error()) } if _, exists := m.cfg.Profiles[name]; exists { - return "", modalFieldError("name", fmt.Sprintf("profile %q already exists", name)) + return "", fieldError("name", fmt.Sprintf("profile %q already exists", name)) } } else { name = m.originalName } - source := m.composedSource() + source := m.currentSource().Compose() if source == "" { - return "", modalFieldError("source_value", "source details are required") + return "", fieldError("source_value", "source details are required") } if _, err := parseSourceURI(source); err != nil { - return "", modalFieldError("source_value", fmt.Sprintf("invalid source: %v", err)) + return "", fieldError("source_value", fmt.Sprintf("invalid source: %v", err)) } - storeRef := strings.TrimSpace(m.fieldValue("store")) + storeRef := m.textFieldValue("store") if storeRef == "" { - return "", modalFieldError("store", "store reference is required") + return "", fieldError("store", "store reference is required") } if _, ok := m.cfg.Stores[storeRef]; !ok { - return "", modalFieldError("store", fmt.Sprintf("unknown store %q", storeRef)) + return "", fieldError("store", fmt.Sprintf("unknown store %q", storeRef)) } - authRef := strings.TrimSpace(m.fieldValue("auth")) - provider := profileProviderFromSource(source) + authRef := m.textFieldValue("auth") + provider := m.currentSource().Provider() if provider != "" { if authRef == "" { - return "", modalFieldError("auth", fmt.Sprintf("auth reference is required for %s sources", provider)) + return "", fieldError("auth", fmt.Sprintf("auth reference is required for %s sources", provider)) } auth, ok := m.cfg.Auth[authRef] if !ok { - return "", modalFieldError("auth", fmt.Sprintf("unknown auth %q", authRef)) + return "", fieldError("auth", fmt.Sprintf("unknown auth %q", authRef)) } if auth.Provider != provider { - return "", modalFieldError("auth", fmt.Sprintf("auth %q is not a %s entry", authRef, provider)) + return "", fieldError("auth", fmt.Sprintf("auth %q is not a %s entry", authRef, provider)) } } else { authRef = "" @@ -313,62 +321,21 @@ func (m *tuiProfileModal) clearError() { m.modal.ErrorField = "" } -func (m *tuiProfileModal) loadSource(raw string) { - sourceType := firstNonEmpty(sourceTypeFromSource(raw), "local") - sourceValue := sourceValueFromSource(raw) - if field := m.fieldByKey("source_type"); field != nil { - field.Value = sourceType - } - if field := m.fieldByKey("source_value"); field != nil { - field.Value = sourceValue +func (m *tuiProfileModal) currentSource() tuiProfileSource { + return tuiProfileSource{ + Type: firstNonEmpty(m.fieldValue("source_type"), "local"), + Value: m.fieldValue("source_value"), } - m.syncSourceFieldMetadata() } -func (m *tuiProfileModal) composedSource() string { - sourceType := m.fieldValue("source_type") - sourceValue := strings.TrimSpace(m.fieldValue("source_value")) - switch sourceType { - case "local": - return "local:" + sourceValue - case "sftp": - if sourceValue == "" { - return "" - } - return "sftp://" + sourceValue - case "gdrive", "gdrive-changes", "onedrive", "onedrive-changes": - switch { - case sourceValue == "", sourceValue == "/": - return sourceType - case strings.HasPrefix(sourceValue, "/"): - return sourceType + ":" + sourceValue - default: - return sourceType + "://" + sourceValue - } - default: - return sourceValue - } -} - -func (m *tuiProfileModal) syncSourceFieldMetadata() { +func (m *tuiProfileModal) updateSourceFieldMetadata() { field := m.fieldByKey("source_value") if field == nil { return } - switch m.fieldValue("source_type") { - case "local": - field.Label = "Path" - field.Required = true - case "sftp": - field.Label = "Target" - field.Required = true - case "gdrive", "gdrive-changes", "onedrive", "onedrive-changes": - field.Label = "Location" - field.Required = false - default: - field.Label = "Source" - field.Required = true - } + source := m.currentSource() + field.Label = source.DetailLabel() + field.Required = source.DetailRequired() } func (m *tuiProfileModal) fieldByKey(key string) *tui.ModalField { @@ -388,6 +355,17 @@ func (m *tuiProfileModal) fieldValue(key string) string { return field.Value } +func (m *tuiProfileModal) textFieldValue(key string) string { + return strings.TrimSpace(m.fieldValue(key)) +} + +func (m *tuiProfileModal) selectedFieldKey() string { + if m.modal.Selected < 0 || m.modal.Selected >= len(m.modal.Fields) { + return "" + } + return m.modal.Fields[m.modal.Selected].Key +} + func (s *tuiSession) runProfileModal(ctx context.Context, existingName string, editing bool) error { modal, err := newTUIProfileModal(s.profilesFile, existingName, editing) if err != nil { @@ -561,59 +539,27 @@ func profileModalTitle(editing bool) string { return "Create Profile" } -func profileModalSubtitle(m *tuiProfileModal) string { - m.syncSourceFieldMetadata() - source := m.composedSource() - provider := profileProviderFromSource(source) +func profileModalSubtitle(source tuiProfileSource, cfg *cloudstic.ProfilesConfig) string { + provider := source.Provider() switch { case provider == "": - return sourceTypeDescription(m.fieldValue("source_type")) - case len(profileAuthOptions(m.cfg, provider)) == 0: + return source.Description() + case len(profileAuthOptions(cfg, provider)) == 0: return fmt.Sprintf("No %s auth refs available yet.", provider) default: return fmt.Sprintf("Source requires a %s auth reference.", provider) } } -func sourceFieldExamples(m *tuiProfileModal) []string { - selected := "" - if m.modal.Selected >= 0 && m.modal.Selected < len(m.modal.Fields) { - selected = m.modal.Fields[m.modal.Selected].Key - } - if selected != "source_value" { +func sourceFieldExamples(selectedField string, source tuiProfileSource) []string { + if selectedField != "source_value" { return nil } - switch m.fieldValue("source_type") { - case "local": - return []string{fmt.Sprintf("%sExample:%s /Users/me/Documents", ui.Dim, ui.Reset)} - case "sftp": - return []string{fmt.Sprintf("%sExample:%s backup@host.example.com/data", ui.Dim, ui.Reset)} - case "gdrive", "gdrive-changes": - return []string{fmt.Sprintf("%sExamples:%s /Team Folder or Shared Drive/Finance (leave empty for the whole drive)", ui.Dim, ui.Reset)} - case "onedrive", "onedrive-changes": - return []string{fmt.Sprintf("%sExamples:%s /Documents or Shared Library/Reports (leave empty for the whole drive)", ui.Dim, ui.Reset)} - default: + example := source.ExampleText() + if example == "" { return nil } -} - -func sourceTypeDescription(sourceType string) string { - switch sourceType { - case "local": - return "Back up a local filesystem path." - case "sftp": - return "Back up files from an SFTP server." - case "gdrive": - return "Back up Google Drive with a full scan." - case "gdrive-changes": - return "Back up Google Drive incrementally via the Changes API." - case "onedrive": - return "Back up OneDrive with a full scan." - case "onedrive-changes": - return "Back up OneDrive incrementally via the delta API." - default: - return "Configure the source details below." - } + return []string{fmt.Sprintf("%s%s%s", ui.Dim, example, ui.Reset)} } func firstNonEmpty(values ...string) string { @@ -684,19 +630,17 @@ func moveDefaultToFront(options []string, current string) { } } -func modalFieldError(field, message string) error { - return fmt.Errorf("%s::%s", field, message) +type tuiFieldError struct { + Field string + Message string } -func modalValidationError(err error) (field, message string) { - if err == nil { - return "", "" - } - parts := strings.SplitN(err.Error(), "::", 2) - if len(parts) == 2 { - return parts[0], parts[1] - } - return "", err.Error() +func (e *tuiFieldError) Error() string { + return e.Message +} + +func fieldError(field, message string) error { + return &tuiFieldError{Field: field, Message: message} } func managementActivity(status tui.ActivityStatus, action, summary string, lines ...string) tui.ActivityPanel { diff --git a/cmd/cloudstic/cmd_tui_profile_source.go b/cmd/cloudstic/cmd_tui_profile_source.go new file mode 100644 index 0000000..160ee56 --- /dev/null +++ b/cmd/cloudstic/cmd_tui_profile_source.go @@ -0,0 +1,99 @@ +package main + +import "strings" + +type tuiProfileSource struct { + Type string + Value string +} + +func newTUIProfileSource(raw string) tuiProfileSource { + return tuiProfileSource{ + Type: firstNonEmpty(sourceTypeFromSource(raw), "local"), + Value: sourceValueFromSource(raw), + } +} + +func (s tuiProfileSource) Compose() string { + value := strings.TrimSpace(s.Value) + switch s.Type { + case "local": + return "local:" + value + case "sftp": + if value == "" { + return "" + } + return "sftp://" + value + case "gdrive", "gdrive-changes", "onedrive", "onedrive-changes": + switch { + case value == "", value == "/": + return s.Type + case strings.HasPrefix(value, "/"): + return s.Type + ":" + value + default: + return s.Type + "://" + value + } + default: + return value + } +} + +func (s tuiProfileSource) Provider() string { + return profileProviderFromSource(s.Compose()) +} + +func (s tuiProfileSource) DetailLabel() string { + switch s.Type { + case "local": + return "Path" + case "sftp": + return "Target" + case "gdrive", "gdrive-changes", "onedrive", "onedrive-changes": + return "Location" + default: + return "Source" + } +} + +func (s tuiProfileSource) DetailRequired() bool { + switch s.Type { + case "gdrive", "gdrive-changes", "onedrive", "onedrive-changes": + return false + default: + return true + } +} + +func (s tuiProfileSource) Description() string { + switch s.Type { + case "local": + return "Back up a local filesystem path." + case "sftp": + return "Back up files from an SFTP server." + case "gdrive": + return "Back up Google Drive with a full scan." + case "gdrive-changes": + return "Back up Google Drive incrementally via the Changes API." + case "onedrive": + return "Back up OneDrive with a full scan." + case "onedrive-changes": + return "Back up OneDrive incrementally via the delta API." + default: + return "Configure the source details below." + } +} + +func (s tuiProfileSource) ExampleText() string { + switch s.Type { + case "local": + return "Example: /Users/me/Documents" + case "sftp": + return "Example: backup@host.example.com/data" + case "gdrive", "gdrive-changes": + return "Examples: /Team Folder or Shared Drive/Finance (leave empty for the whole drive)" + case "onedrive", "onedrive-changes": + return "Examples: /Documents or Shared Library/Reports (leave empty for the whole drive)" + default: + return "" + } +} diff --git a/cmd/cloudstic/cmd_tui_test.go b/cmd/cloudstic/cmd_tui_test.go index a8798be..1d95c3c 100644 --- a/cmd/cloudstic/cmd_tui_test.go +++ b/cmd/cloudstic/cmd_tui_test.go @@ -7,6 +7,7 @@ import ( "errors" "io" "os" + "reflect" "strings" "testing" @@ -15,6 +16,82 @@ import ( xterm "golang.org/x/term" ) +func TestTUIProfileSourceCompose(t *testing.T) { + tests := []struct { + name string + src tuiProfileSource + want string + }{ + {name: "local", src: tuiProfileSource{Type: "local", Value: "/docs"}, want: "local:/docs"}, + {name: "sftp", src: tuiProfileSource{Type: "sftp", Value: "backup@host/data"}, want: "sftp://backup@host/data"}, + {name: "gdrive root", src: tuiProfileSource{Type: "gdrive", Value: ""}, want: "gdrive"}, + {name: "gdrive path", src: tuiProfileSource{Type: "gdrive", Value: "/Team"}, want: "gdrive:/Team"}, + {name: "gdrive drive name", src: tuiProfileSource{Type: "gdrive", Value: "Shared Drive/Finance"}, want: "gdrive://Shared Drive/Finance"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.src.Compose(); got != tt.want { + t.Fatalf("Compose()=%q want %q", got, tt.want) + } + }) + } +} + +func TestTUIProfileModalSubmitReturnsTypedFieldError(t *testing.T) { + dir := t.TempDir() + profilesPath := dir + "/profiles.yaml" + if err := cloudstic.SaveProfilesFile(profilesPath, &cloudstic.ProfilesConfig{ + Version: 1, + Stores: map[string]cloudstic.ProfileStore{ + "remote": {URI: "s3:bucket/test"}, + }, + }); err != nil { + t.Fatalf("SaveProfilesFile: %v", err) + } + + modal, err := newTUIProfileModal(profilesPath, "", false) + if err != nil { + t.Fatalf("newTUIProfileModal: %v", err) + } + modal.fieldByKey("name").Value = "" + + _, err = modal.submit() + if err == nil { + t.Fatalf("expected validation error") + } + fieldErr, ok := err.(*tuiFieldError) + if !ok { + t.Fatalf("expected *tuiFieldError, got %T", err) + } + if fieldErr.Field != "name" { + t.Fatalf("field=%q want name", fieldErr.Field) + } +} + +func TestTUIProfileModalViewDoesNotMutateState(t *testing.T) { + dir := t.TempDir() + profilesPath := dir + "/profiles.yaml" + if err := cloudstic.SaveProfilesFile(profilesPath, &cloudstic.ProfilesConfig{ + Version: 1, + Stores: map[string]cloudstic.ProfileStore{ + "remote": {URI: "s3:bucket/test"}, + }, + }); err != nil { + t.Fatalf("SaveProfilesFile: %v", err) + } + + modal, err := newTUIProfileModal(profilesPath, "", false) + if err != nil { + t.Fatalf("newTUIProfileModal: %v", err) + } + before := modal.modal + _ = modal.View() + after := modal.modal + if !reflect.DeepEqual(before, after) { + t.Fatalf("View mutated modal state") + } +} + func stubTUITestHooks(t *testing.T) { t.Helper()