Skip to content
Merged
72 changes: 49 additions & 23 deletions internal/app/command_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,29 +73,38 @@ func runProfileList(stdout io.Writer) error {
}

func runProfileCreate(cfg cli.Config, stdout io.Writer) error {
if cfg.Provider == "" {
if strings.TrimSpace(cfg.Provider) == "" {
return fmt.Errorf("--provider is required. Specify the TTS provider (gcp, aws, azure, ibm, alibaba)")
}
if cfg.ProfileName == "" {
if strings.TrimSpace(cfg.ProfileName) == "" {
return fmt.Errorf("--name is required. Choose a unique name for this profile")
}
if cfg.APIKey == "" {
return fmt.Errorf("--api-key is required. Get your API key from the provider's console")
}

normalizedProvider := strings.ToLower(strings.TrimSpace(cfg.Provider))

profileKey, providerName, profileName, err := config.BuildProfileKey(normalizedProvider, cfg.ProfileName)
if err != nil {
return err
}
if err := validateProfileCreateProvider(providerName); err != nil {
return err
}

appCfg, err := loadConfig()
if err != nil {
return fmt.Errorf("load config: %w", err)
}

profileKey := cfg.Provider + ":" + cfg.ProfileName
if _, exists := appCfg.Profiles[profileKey]; exists {
return fmt.Errorf("profile '%s' already exists. Choose a different name or delete it first", profileKey)
return fmt.Errorf("profile %q already exists. Choose a different name or delete it first", profileKey)
}

profile := config.Profile{
Provider: cfg.Provider,
Name: cfg.ProfileName,
Provider: providerName,
Name: profileName,
Credentials: map[string]interface{}{
"apiKey": cfg.APIKey,
},
Expand Down Expand Up @@ -146,19 +155,35 @@ func runProfileCreate(cfg cli.Config, stdout io.Writer) error {
return nil
}

func validateProfileCreateProvider(provider string) error {
switch provider {
case "gcp":
return nil
case "aws", "azure", "ibm", "alibaba":
return fmt.Errorf("provider %q is not yet implemented. Available today: gcp", provider)
default:
return fmt.Errorf("unsupported provider %q. Supported providers: gcp", provider)
}
}

func runProfileDelete(cfg cli.Config, stdout io.Writer) error {
appCfg, err := loadConfig()
if err != nil {
return fmt.Errorf("load config: %w", err)
}

if _, exists := appCfg.Profiles[cfg.Profile]; !exists {
return fmt.Errorf("profile '%s' not found. Run 'ttscli profile list' to see available profiles", cfg.Profile)
profileKey, _, _, err := config.ParseProfileKey(cfg.Profile)
if err != nil {
return err
}

if _, exists := appCfg.Profiles[profileKey]; !exists {
return fmt.Errorf("profile %q not found. Run 'ttscli profile list' to see available profiles", profileKey)
}

delete(appCfg.Profiles, cfg.Profile)
delete(appCfg.Profiles, profileKey)

if appCfg.ActiveProvider+":"+appCfg.ActiveProfile == cfg.Profile {
if appCfg.ActiveProvider+":"+appCfg.ActiveProfile == profileKey {
appCfg.ActiveProvider = ""
appCfg.ActiveProfile = ""
for key, profile := range appCfg.Profiles {
Expand All @@ -176,7 +201,7 @@ func runProfileDelete(cfg cli.Config, stdout io.Writer) error {
return fmt.Errorf("save config: %w", err)
}

fmt.Fprintf(stdout, "✓ Profile deleted: %s\n", cfg.Profile)
fmt.Fprintf(stdout, "✓ Profile deleted: %s\n", profileKey)
return nil
}

Expand All @@ -186,17 +211,13 @@ func runProfileUse(cfg cli.Config, stdout io.Writer) error {
return fmt.Errorf("load config: %w", err)
}

parts := strings.Split(cfg.Profile, ":")
if len(parts) != 2 {
return fmt.Errorf("invalid profile format. Expected 'provider:name' (e.g., gcp:default)")
profileKey, provider, name, err := config.ParseProfileKey(cfg.Profile)
if err != nil {
return err
}

provider := parts[0]
name := parts[1]
profileKey := cfg.Profile

if _, exists := appCfg.Profiles[profileKey]; !exists {
return fmt.Errorf("profile '%s' not found. Run 'ttscli profile list' to see available profiles", profileKey)
return fmt.Errorf("profile %q not found. Run 'ttscli profile list' to see available profiles", profileKey)
}

appCfg.ActiveProvider = provider
Expand All @@ -220,19 +241,24 @@ func runProfileGet(cfg cli.Config, stdout io.Writer) error {
return fmt.Errorf("load config: %w", err)
}

profile, err := getProfile(appCfg, cfg.Profile)
profileKey, _, _, err := config.ParseProfileKey(cfg.Profile)
if err != nil {
return err
}

profile, exists := appCfg.Profiles[profileKey]
if !exists {
return fmt.Errorf("profile %q not found. Run 'ttscli profile list' to see available profiles", profileKey)
}

isActive := ""
if profile.Provider == appCfg.ActiveProvider && profile.Name == appCfg.ActiveProfile {
isActive = " (active)"
}

fmt.Fprintln(stdout, "Profile Details")
fmt.Fprintln(stdout, "───────────────")
fmt.Fprintf(stdout, "Profile Key: %s%s\n", cfg.Profile, isActive)
fmt.Fprintf(stdout, "Profile Key: %s%s\n", profileKey, isActive)
fmt.Fprintf(stdout, "Provider: %s\n", profile.Provider)
fmt.Fprintf(stdout, "Name: %s\n", profile.Name)
fmt.Fprintf(stdout, "API Key: %s\n", maskAPIKey(resolveProfileAPIKey(profile)))
Expand All @@ -248,7 +274,7 @@ func runProfileGet(cfg cli.Config, stdout io.Writer) error {
}
fmt.Fprintln(stdout)
fmt.Fprintln(stdout, "Usage:")
fmt.Fprintf(stdout, " ttscli speak --text \"Hello\" --profile %s\n", cfg.Profile)
fmt.Fprintf(stdout, " ttscli save --text \"Hello\" --out speech.mp3 --profile %s\n", cfg.Profile)
fmt.Fprintf(stdout, " ttscli speak --text \"Hello\" --profile %s\n", profileKey)
fmt.Fprintf(stdout, " ttscli save --text \"Hello\" --out speech.mp3 --profile %s\n", profileKey)
return nil
}
157 changes: 148 additions & 9 deletions internal/app/command_profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,56 @@ func TestRunProfileCreateSuccess(t *testing.T) {
}
}

func TestRunProfileCreateNormalizesProviderName(t *testing.T) {
reset := stubAppDeps()
defer reset()

loadConfig = func() (config.Config, error) {
return config.Config{Profiles: map[string]config.Profile{}}, nil
}
newProvider = func(profile config.Profile) (tts.Provider, error) {
if profile.Provider != "gcp" {
t.Fatalf("expected normalized provider gcp, got %q", profile.Provider)
}
return &fakeTTSClient{
listVoicesFn: func(ctx context.Context, lang string) ([]tts.Voice, error) {
return []tts.Voice{{Name: "en-US-Neural2-F"}}, nil
},
synthesizeFn: func(ctx context.Context, text, lang, voice, enc string) ([]byte, error) {
return nil, nil
},
}, nil
}

var saved config.Config
saveConfig = func(cfg config.Config) error {
saved = cfg
return nil
}

var stdout bytes.Buffer
cfg := cli.Config{Provider: " GCP ", ProfileName: "work", APIKey: "test-key"}
if err := runProfileCreate(cfg, &stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, exists := saved.Profiles["gcp:work"]; !exists {
t.Fatalf("expected normalized profile key gcp:work in saved config, got: %#v", saved.Profiles)
}
if !strings.Contains(stdout.String(), "Profile created: gcp:work") {
t.Errorf("expected normalized creation message, got: %q", stdout.String())
}
}

func TestRunProfileCreateAlreadyExists(t *testing.T) {
reset := stubAppDeps()
defer reset()

// stubAppDeps has gcp:default — try to create it again.
cfg := cli.Config{Provider: "gcp", ProfileName: "default", APIKey: "test-key"}
err := runProfileCreate(cfg, &bytes.Buffer{})
if err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("expected already exists error, got: %v", err)
want := `profile "gcp:default" already exists. Choose a different name or delete it first`
if err == nil || err.Error() != want {
t.Fatalf("expected %q, got: %v", want, err)
}
}

Expand All @@ -132,6 +173,81 @@ func TestRunProfileCreateMissingName(t *testing.T) {
}
}

func TestRunProfileCreateRejectsInvalidProvider(t *testing.T) {
reset := stubAppDeps()
defer reset()

cfg := cli.Config{Provider: "gc:p", ProfileName: "work", APIKey: "test-key"}
err := runProfileCreate(cfg, &bytes.Buffer{})
if err == nil || !strings.Contains(err.Error(), "invalid provider") {
t.Fatalf("expected invalid provider error, got: %v", err)
}
}

func TestRunProfileCreateRejectsUnimplementedProvider(t *testing.T) {
reset := stubAppDeps()
defer reset()

providerCalled := false
newProvider = func(profile config.Profile) (tts.Provider, error) {
providerCalled = true
return nil, nil
}

cfg := cli.Config{Provider: "aws", ProfileName: "work", APIKey: "test-key"}
err := runProfileCreate(cfg, &bytes.Buffer{})
want := `provider "aws" is not yet implemented. Available today: gcp`
if err == nil || err.Error() != want {
t.Fatalf("expected %q, got: %v", want, err)
}
if providerCalled {
t.Fatal("expected provider initialization to be skipped for unimplemented provider")
}
}

func TestRunProfileCreateRejectsUnknownProvider(t *testing.T) {
reset := stubAppDeps()
defer reset()

providerCalled := false
newProvider = func(profile config.Profile) (tts.Provider, error) {
providerCalled = true
return nil, nil
}

cfg := cli.Config{Provider: "openai", ProfileName: "work", APIKey: "test-key"}
err := runProfileCreate(cfg, &bytes.Buffer{})
want := `unsupported provider "openai". Supported providers: gcp`
if err == nil || err.Error() != want {
t.Fatalf("expected %q, got: %v", want, err)
}
if providerCalled {
t.Fatal("expected provider initialization to be skipped for unsupported provider")
}
}

func TestRunProfileCreateRejectsInvalidProfileName(t *testing.T) {
reset := stubAppDeps()
defer reset()

cfg := cli.Config{Provider: "gcp", ProfileName: "wo:rk", APIKey: "test-key"}
err := runProfileCreate(cfg, &bytes.Buffer{})
if err == nil || !strings.Contains(err.Error(), "invalid profile name") {
t.Fatalf("expected invalid profile name error, got: %v", err)
}
}

func TestRunProfileCreateRejectsWhitespaceOnlyName(t *testing.T) {
reset := stubAppDeps()
defer reset()

cfg := cli.Config{Provider: "gcp", ProfileName: " ", APIKey: "test-key"}
err := runProfileCreate(cfg, &bytes.Buffer{})
if err == nil || !strings.Contains(err.Error(), "--name is required") {
t.Fatalf("expected name required error, got: %v", err)
}
}

func TestRunProfileCreateMissingAPIKey(t *testing.T) {
reset := stubAppDeps()
defer reset()
Expand Down Expand Up @@ -170,8 +286,9 @@ func TestRunProfileDeleteNotFound(t *testing.T) {
defer reset()

err := runProfileDelete(cli.Config{Profile: "gcp:nonexistent"}, &bytes.Buffer{})
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected not found error, got: %v", err)
want := `profile "gcp:nonexistent" not found. Run 'ttscli profile list' to see available profiles`
if err == nil || err.Error() != want {
t.Fatalf("expected %q, got: %v", want, err)
}
}

Expand Down Expand Up @@ -279,18 +396,29 @@ func TestRunProfileUseInvalidFormat(t *testing.T) {
defer reset()

err := runProfileUse(cli.Config{Profile: "invalid-format"}, &bytes.Buffer{})
if err == nil || !strings.Contains(err.Error(), "invalid profile format") {
if err == nil || !strings.Contains(err.Error(), "invalid profile key") {
t.Fatalf("expected invalid format error, got: %v", err)
}
}

func TestRunProfileDeleteInvalidFormat(t *testing.T) {
reset := stubAppDeps()
defer reset()

err := runProfileDelete(cli.Config{Profile: "gcp"}, &bytes.Buffer{})
if err == nil || !strings.Contains(err.Error(), "invalid profile key") {
t.Fatalf("expected invalid profile key error, got: %v", err)
}
}

func TestRunProfileUseNotFound(t *testing.T) {
reset := stubAppDeps()
defer reset()

err := runProfileUse(cli.Config{Profile: "gcp:nonexistent"}, &bytes.Buffer{})
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected not found error, got: %v", err)
want := `profile "gcp:nonexistent" not found. Run 'ttscli profile list' to see available profiles`
if err == nil || err.Error() != want {
t.Fatalf("expected %q, got: %v", want, err)
}
}

Expand All @@ -313,8 +441,19 @@ func TestRunProfileGetNotFound(t *testing.T) {
defer reset()

err := runProfileGet(cli.Config{Profile: "gcp:nonexistent"}, &bytes.Buffer{})
if err == nil {
t.Fatal("expected error for missing profile")
want := `profile "gcp:nonexistent" not found. Run 'ttscli profile list' to see available profiles`
if err == nil || err.Error() != want {
t.Fatalf("expected %q, got: %v", want, err)
}
}

func TestRunProfileGetInvalidFormat(t *testing.T) {
reset := stubAppDeps()
defer reset()

err := runProfileGet(cli.Config{Profile: "gcp:"}, &bytes.Buffer{})
if err == nil || !strings.Contains(err.Error(), "invalid profile key") {
t.Fatalf("expected invalid profile key error, got: %v", err)
}
}

Expand Down
8 changes: 6 additions & 2 deletions internal/app/command_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ func runSetupCommand(stdout, stderr io.Writer) error {
profileName = "default"
}

profileKey = "gcp:" + profileName
var buildErr error
profileKey, _, profileName, buildErr = config.BuildProfileKey("gcp", profileName)
if buildErr != nil {
return buildErr
}
if _, exists := appCfg.Profiles[profileKey]; exists {
fmt.Fprintf(stdout, "Profile '%s' already exists.\n", profileKey)
fmt.Fprintf(stdout, "Tip: Use 'ttscli profile use %s' to activate it.\n", profileKey)
Expand Down Expand Up @@ -144,7 +148,7 @@ func runSetupCommand(stdout, stderr io.Writer) error {
if err != nil {
return fmt.Errorf("resolve config path: %w", err)
}

fmt.Fprintln(stdout)
fmt.Fprintln(stdout, "✓ Setup complete!")
fmt.Fprintln(stdout)
Expand Down
Loading