From f760523ca0e9a8136bb41085de7dd6bfa4003acd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 5 May 2026 02:23:27 -0700 Subject: [PATCH] fix(tui): allow empty json smoke --- internal/cli/app.go | 62 ++++++++++++++++++++++++++++++++++------ internal/cli/app_test.go | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 9 deletions(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index 3551577..d9611d1 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -1083,23 +1083,35 @@ func (a *App) runTUI(ctx context.Context, args []string) error { rt, err = a.openLocalRuntimeReadOnly(ctx) } if err != nil { + if !interactive && errors.Is(err, os.ErrNotExist) { + cfg := config.Default() + if cfgErr := cfg.Normalize(); cfgErr != nil { + return cfgErr + } + sort, sortErr := resolveTUISort(*sortMode, cfg) + if sortErr != nil { + return sortErr + } + return a.writeOutput("tui", emptyClusterBrowserPayload(ctx, cfg, cfg.DBPath, sort, minSize, limit, *hideClosed), true) + } return err } defer rt.Store.Close() repo, inferred, err := a.resolveOptionalRepository(ctx, rt, fs.Args()) if err != nil { + if !interactive && len(fs.Args()) == 0 && strings.Contains(err.Error(), "no local repositories found") { + sort, sortErr := resolveTUISort(*sortMode, rt.Config) + if sortErr != nil { + return sortErr + } + return a.writeOutput("tui", emptyClusterBrowserPayload(ctx, rt.Config, rt.SourceDBPath, sort, minSize, limit, *hideClosed), true) + } return err } - sort := strings.TrimSpace(*sortMode) - if sort == "" { - sort = strings.TrimSpace(rt.Config.TUI.DefaultSort) - } - if sort == "" { - sort = "size" - } - if sort != "recent" && sort != "oldest" && sort != "size" { - return usageErr(fmt.Errorf("unsupported sort %q", sort)) + sort, err := resolveTUISort(*sortMode, rt.Config) + if err != nil { + return err } showClosed := !*hideClosed || *includeClosed @@ -1154,6 +1166,38 @@ func (a *App) runTUI(ctx context.Context, args []string) error { return a.runInteractiveTUI(ctx, rt.Store, repo.ID, payload) } +func resolveTUISort(raw string, cfg config.Config) (string, error) { + sort := strings.TrimSpace(raw) + if sort == "" { + sort = strings.TrimSpace(cfg.TUI.DefaultSort) + } + if sort == "" { + sort = "size" + } + if sort != "recent" && sort != "oldest" && sort != "size" { + return "", usageErr(fmt.Errorf("unsupported sort %q", sort)) + } + return sort, nil +} + +func emptyClusterBrowserPayload(ctx context.Context, cfg config.Config, sourceDBPath, sort string, minSize, limit int, hideClosed bool) clusterBrowserPayload { + if strings.TrimSpace(sourceDBPath) == "" { + sourceDBPath = cfg.DBPath + } + return clusterBrowserPayload{ + Mode: "cluster-browser", + DBSource: databaseSourceKind(sourceDBPath), + DBLocation: databaseSourceLocation(ctx, sourceDBPath), + Sort: sort, + MinSize: minSize, + Limit: limit, + HideClosed: hideClosed, + EmbedModel: cfg.OpenAI.EmbedModel, + EmbeddingBasis: cfg.EmbeddingBasis, + Clusters: []store.ClusterSummary{}, + } +} + func databaseSourceKind(dbPath string) string { if _, ok := portableStoreRoot(dbPath); ok { return "remote" diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index e61349b..7935a92 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -1022,6 +1023,60 @@ func TestTUIInfersRepository(t *testing.T) { } } +func TestTUIJSONUsesDefaultsWhenConfigMissing(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + configPath := filepath.Join(dir, "missing.toml") + t.Setenv("GITCRAWL_DB_PATH", filepath.Join(dir, "missing.db")) + + run := New() + var stdout bytes.Buffer + run.Stdout = &stdout + if err := run.Run(ctx, []string{"--config", configPath, "tui", "--json"}); err != nil { + t.Fatalf("tui: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("decode tui payload: %v\n%s", err, stdout.String()) + } + if payload["mode"] != "cluster-browser" { + t.Fatalf("mode = %#v", payload["mode"]) + } + clusters, ok := payload["clusters"].([]any) + if !ok || len(clusters) != 0 { + t.Fatalf("clusters = %#v", payload["clusters"]) + } + if _, err := os.Stat(configPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("config file should not be created, stat err=%v", err) + } +} + +func TestTUIJSONHandlesEmptyStoreWithoutRepository(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + dbPath := filepath.Join(dir, "gitcrawl.db") + app := New() + if err := app.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil { + t.Fatalf("init: %v", err) + } + + run := New() + var stdout bytes.Buffer + run.Stdout = &stdout + if err := run.Run(ctx, []string{"--config", configPath, "tui", "--json"}); err != nil { + t.Fatalf("tui: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("decode tui payload: %v\n%s", err, stdout.String()) + } + clusters, ok := payload["clusters"].([]any) + if !ok || len(clusters) != 0 { + t.Fatalf("clusters = %#v", payload["clusters"]) + } +} + func TestTUIRequiresInteractiveTerminalByDefault(t *testing.T) { ctx := context.Background() dir := t.TempDir()