diff --git a/internal/cli/app.go b/internal/cli/app.go index 0b58d50..190ac01 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -1,17 +1,40 @@ package cli import ( + "context" "fmt" + "io" + "os" "email-mcp/internal/secretstore" ) +type MCPRunner interface { + Run(ctx context.Context) error +} + type App struct { - store secretstore.Store + prompter SetupPrompter + store secretstore.Store + runner MCPRunner + stderr io.Writer } func NewApp() *App { - return &App{} + return NewAppWithDependencies(nil, nil, nil, os.Stderr) +} + +func NewAppWithDependencies(prompter SetupPrompter, store secretstore.Store, runner MCPRunner, stderr io.Writer) *App { + if stderr == nil { + stderr = io.Discard + } + + return &App{ + prompter: prompter, + store: store, + runner: runner, + stderr: stderr, + } } func (a *App) Run(args []string) error { @@ -20,9 +43,39 @@ func (a *App) Run(args []string) error { } switch args[0] { - case "setup", "mcp": - return nil + case "setup": + return a.runSetup(context.Background()) + case "mcp": + return a.runMCP(context.Background()) default: return fmt.Errorf("unknown command: %s", args[0]) } } + +func (a *App) runSetup(ctx context.Context) error { + if a.prompter == nil { + return fmt.Errorf("setup prompter is not configured") + } + if a.store == nil { + return fmt.Errorf("secret store is not configured") + } + + cred, err := a.prompter.PromptSetup(ctx) + if err != nil { + return err + } + if err := cred.Validate(); err != nil { + return err + } + if err := a.store.Save(ctx, secretstore.DefaultAccountKey, cred); err != nil { + return err + } + return nil +} + +func (a *App) runMCP(ctx context.Context) error { + if a.runner == nil { + return fmt.Errorf("mcp runner is not configured") + } + return a.runner.Run(ctx) +} diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 19b263a..386d1bc 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -1,12 +1,59 @@ package cli import ( + "bytes" + "context" + "errors" "strings" "testing" + + "email-mcp/internal/secretstore" ) var _ func() *App = NewApp +type promptStub struct { + credential secretstore.Credential + err error + called bool +} + +func (p *promptStub) PromptSetup(context.Context) (secretstore.Credential, error) { + p.called = true + if p.err != nil { + return secretstore.Credential{}, p.err + } + return p.credential, nil +} + +type storeStub struct { + saved secretstore.Credential + savedKey string + saveErr error + saveCalled bool +} + +func (s *storeStub) Save(_ context.Context, key string, cred secretstore.Credential) error { + s.saveCalled = true + s.savedKey = key + s.saved = cred + return s.saveErr +} + +func (s *storeStub) Load(context.Context, string) (secretstore.Credential, error) { + return secretstore.Credential{}, nil +} + +type runnerStub struct { + err error + called bool +} + +func (r *runnerStub) Run(context.Context) error { + r.called = true + return r.err +} + func TestAppRunRejectsUnknownCommand(t *testing.T) { app := NewApp() @@ -28,13 +75,97 @@ func TestAppRunShowsUsageWhenNoArgsProvided(t *testing.T) { } } -func TestAppRunAcceptsSetupAndMcp(t *testing.T) { +func TestAppRunSetupPromptsAndSavesDefaultCredential(t *testing.T) { + store := &storeStub{} + prompter := &promptStub{ + credential: secretstore.Credential{ + Host: "imap.example.com", + Username: "alice", + Password: "secret", + }, + } + app := NewAppWithDependencies(prompter, store, nil, &bytes.Buffer{}) + + if err := app.Run([]string{"setup"}); err != nil { + t.Fatalf("Run returned error: %v", err) + } + + if !prompter.called { + t.Fatal("expected setup prompter to be called") + } + if !store.saveCalled { + t.Fatal("expected store Save to be called") + } + if store.savedKey != secretstore.DefaultAccountKey { + t.Fatalf("expected saved key %q, got %q", secretstore.DefaultAccountKey, store.savedKey) + } + if store.saved.Host != "imap.example.com" || store.saved.Username != "alice" || store.saved.Password != "secret" { + t.Fatalf("unexpected saved credential: %#v", store.saved) + } +} + +func TestAppRunSetupRejectsInvalidCredential(t *testing.T) { + store := &storeStub{} + app := NewAppWithDependencies(&promptStub{ + credential: secretstore.Credential{ + Host: "imap.example.com", + Username: "alice", + }, + }, store, nil, &bytes.Buffer{}) + + err := app.Run([]string{"setup"}) + if err == nil { + t.Fatal("expected setup to fail for invalid credential") + } + if !strings.Contains(err.Error(), "password is required") { + t.Fatalf("expected validation error, got %v", err) + } + if store.saveCalled { + t.Fatal("expected store Save not to be called when credential is invalid") + } +} + +func TestAppRunReturnsPromptError(t *testing.T) { + expectedErr := errors.New("prompt failed") + app := NewAppWithDependencies(&promptStub{err: expectedErr}, &storeStub{}, nil, &bytes.Buffer{}) + + err := app.Run([]string{"setup"}) + if !errors.Is(err, expectedErr) { + t.Fatalf("expected prompt error %v, got %v", expectedErr, err) + } +} + +func TestAppRunMCPDelegatesToRunner(t *testing.T) { + runner := &runnerStub{} + app := NewAppWithDependencies(nil, nil, runner, &bytes.Buffer{}) + + if err := app.Run([]string{"mcp"}); err != nil { + t.Fatalf("expected mcp command to succeed, got %v", err) + } + if !runner.called { + t.Fatal("expected MCP runner to be called") + } +} + +func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) { app := NewApp() - for _, command := range []string{"setup", "mcp"} { - t.Run(command, func(t *testing.T) { - if err := app.Run([]string{command}); err != nil { - t.Fatalf("expected %q to be accepted, got error: %v", command, err) + tests := []struct { + command string + want string + }{ + {command: "setup", want: "setup prompter is not configured"}, + {command: "mcp", want: "mcp runner is not configured"}, + } + + for _, tt := range tests { + t.Run(tt.command, func(t *testing.T) { + err := app.Run([]string{tt.command}) + if err == nil { + t.Fatalf("expected %s to fail without dependencies", tt.command) + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("expected error to contain %q, got %v", tt.want, err) } }) } diff --git a/internal/cli/setup.go b/internal/cli/setup.go new file mode 100644 index 0000000..cfe80ad --- /dev/null +++ b/internal/cli/setup.go @@ -0,0 +1,71 @@ +package cli + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" + + "email-mcp/internal/secretstore" +) + +type SetupPrompter interface { + PromptSetup(ctx context.Context) (secretstore.Credential, error) +} + +type InteractiveSetupPrompter struct { + input *bufio.Reader + output io.Writer +} + +func NewInteractiveSetupPrompter(input io.Reader, output io.Writer) *InteractiveSetupPrompter { + if input == nil { + input = strings.NewReader("") + } + if output == nil { + output = io.Discard + } + + return &InteractiveSetupPrompter{ + input: bufio.NewReader(input), + output: output, + } +} + +func (p *InteractiveSetupPrompter) PromptSetup(context.Context) (secretstore.Credential, error) { + host, err := p.prompt("IMAP host: ") + if err != nil { + return secretstore.Credential{}, err + } + username, err := p.prompt("Username: ") + if err != nil { + return secretstore.Credential{}, err + } + password, err := p.prompt("Password: ") + if err != nil { + return secretstore.Credential{}, err + } + + cred := secretstore.Credential{ + Host: host, + Username: username, + Password: password, + } + if err := cred.Validate(); err != nil { + return secretstore.Credential{}, err + } + return cred, nil +} + +func (p *InteractiveSetupPrompter) prompt(label string) (string, error) { + if _, err := fmt.Fprint(p.output, label); err != nil { + return "", err + } + + value, err := p.input.ReadString('\n') + if err != nil && err != io.EOF { + return "", err + } + return strings.TrimSpace(value), nil +} diff --git a/internal/cli/setup_test.go b/internal/cli/setup_test.go new file mode 100644 index 0000000..9c88bf7 --- /dev/null +++ b/internal/cli/setup_test.go @@ -0,0 +1,39 @@ +package cli + +import ( + "bytes" + "context" + "strings" + "testing" +) + +func TestInteractiveSetupPrompterPromptSetupCollectsCredential(t *testing.T) { + input := strings.NewReader(" imap.example.com \n alice \n secret \n") + output := &bytes.Buffer{} + prompter := NewInteractiveSetupPrompter(input, output) + + cred, err := prompter.PromptSetup(context.Background()) + if err != nil { + t.Fatalf("PromptSetup returned error: %v", err) + } + + if cred.Host != "imap.example.com" || cred.Username != "alice" || cred.Password != "secret" { + t.Fatalf("unexpected credential: %#v", cred) + } + if got := output.String(); got != "IMAP host: Username: Password: " { + t.Fatalf("unexpected prompts: %q", got) + } +} + +func TestInteractiveSetupPrompterPromptSetupRejectsMissingFields(t *testing.T) { + input := strings.NewReader("imap.example.com\nalice\n \n") + prompter := NewInteractiveSetupPrompter(input, &bytes.Buffer{}) + + _, err := prompter.PromptSetup(context.Background()) + if err == nil { + t.Fatal("expected validation error") + } + if !strings.Contains(err.Error(), "password is required") { + t.Fatalf("expected password validation error, got %v", err) + } +}