diff --git a/internal/cli/root.go b/internal/cli/root.go index 2549840..30d992d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -202,19 +202,56 @@ func newProjectNextCmd() *cobra.Command { } func newProjectRemoveCmd() *cobra.Command { - return &cobra.Command{ - Use: "remove ", + removeAll := false + removeCmd := &cobra.Command{ + Use: "remove [name]", Short: "Remove configured project", - Args: cobra.ExactArgs(1), + Args: func(cmd *cobra.Command, args []string) error { + return validateProjectRemoveArgs(removeAll, cmd, args) + }, RunE: func(cmd *cobra.Command, args []string) error { - if err := withConfigStore(func(store *config.Store) error { - return store.RemoveProject(args[0]) - }); err != nil { - return fmt.Errorf("remove project: %w", err) - } - return nil + return runProjectRemove(removeAll, args) }, } + removeCmd.Flags().BoolVar(&removeAll, "all", false, "Remove all configured projects and tokens") + + return removeCmd +} + +func validateProjectRemoveArgs(removeAll bool, cmd *cobra.Command, args []string) error { + if removeAll { + if len(args) > 0 { + return errors.New("cannot use --all with a project name") + } + return nil + } + if len(args) == 0 { + return errors.New("specify a project name or use --all") + } + if len(args) > 1 { + return cobra.MaximumNArgs(1)(cmd, args) + } + + return nil +} + +func runProjectRemove(removeAll bool, args []string) error { + if removeAll { + if err := withConfigStore(func(store *config.Store) error { + return store.RemoveAllProjects() + }); err != nil { + return fmt.Errorf("remove projects: %w", err) + } + return nil + } + + if err := withConfigStore(func(store *config.Store) error { + return store.RemoveProject(args[0]) + }); err != nil { + return fmt.Errorf("remove project: %w", err) + } + + return nil } func runActive(parent context.Context, flags rootFlags) error { diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 158d432..53ecad9 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -166,6 +166,59 @@ func TestProjectCommandsUseNextRemove(t *testing.T) { runRootCommand(t, "project", "remove", "beta") } +func TestProjectRemoveAll(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + store := config.NewStoreAtPath(configPath) + if err := store.AddProject("alpha", "t1"); err != nil { + t.Fatalf("AddProject() error = %v", err) + } + if err := store.AddProject("beta", "t2"); err != nil { + t.Fatalf("AddProject() error = %v", err) + } + + restoreStore := overrideConfigStore(func() (*config.Store, error) { + return store, nil + }) + defer restoreStore() + + runRootCommand(t, "project", "remove", "--all") + + file, err := store.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if file.ActiveProject != "" { + t.Fatalf("ActiveProject = %q, want empty", file.ActiveProject) + } + if len(file.Projects) != 0 { + t.Fatalf("Projects length = %d, want 0", len(file.Projects)) + } +} + +func TestProjectRemoveValidation(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + store := config.NewStoreAtPath(configPath) + + restoreStore := overrideConfigStore(func() (*config.Store, error) { + return store, nil + }) + defer restoreStore() + + cmd := NewRootCmd() + cmd.SetArgs([]string{"project", "remove"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "specify a project name or use --all") { + t.Fatalf("expected missing-args remove error, got %v", err) + } + + cmd = NewRootCmd() + cmd.SetArgs([]string{"project", "remove", "alpha", "--all"}) + err = cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "cannot use --all with a project name") { + t.Fatalf("expected mixed remove args/flag error, got %v", err) + } +} + func TestRootCommandDefaultRunsActive(t *testing.T) { stdout := setupServerAndStdout(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { diff --git a/internal/config/store.go b/internal/config/store.go index 8314e99..730c5c8 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -133,6 +133,10 @@ func (s *Store) RemoveProject(name string) error { return s.Save(file) } +func (s *Store) RemoveAllProjects() error { + return s.Save(File{}) +} + func (s *Store) UseProject(name string) error { file, err := s.Load() if err != nil { diff --git a/internal/config/store_test.go b/internal/config/store_test.go index 500b7a1..4f31c09 100644 --- a/internal/config/store_test.go +++ b/internal/config/store_test.go @@ -77,6 +77,37 @@ func TestStoreErrors(t *testing.T) { } } +func TestStoreRemoveAllProjects(t *testing.T) { + t.Parallel() + + store, _ := newTempStore(t) + if err := store.AddProject("alpha", "token-a"); err != nil { + t.Fatalf("AddProject() error = %v", err) + } + if err := store.AddProject("beta", "token-b"); err != nil { + t.Fatalf("AddProject() error = %v", err) + } + + if err := store.RemoveAllProjects(); err != nil { + t.Fatalf("RemoveAllProjects() error = %v", err) + } + + file, err := store.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if file.ActiveProject != "" { + t.Fatalf("ActiveProject = %q, want empty", file.ActiveProject) + } + if len(file.Projects) != 0 { + t.Fatalf("Projects length = %d, want 0", len(file.Projects)) + } + + if _, _, err := store.ResolveToken(""); err == nil { + t.Fatalf("expected no configured projects error") + } +} + func TestStoreAddProjectUpdatesExisting(t *testing.T) { t.Parallel()