From 335633c9c1df68f286d4c9169a2ff92f3b16d8ce Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:35:05 -0700 Subject: [PATCH] Add JSON output to clean command Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e02217cd-ffed-496e-b8ea-848a781cda64 --- cmd/clean.go | 66 ++++++++++++++++++++++++++++++++++++++--------- cmd/clean_test.go | 52 +++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/cmd/clean.go b/cmd/clean.go index b77b39af..04a30d87 100644 --- a/cmd/clean.go +++ b/cmd/clean.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "fmt" "io" "io/fs" @@ -21,6 +22,21 @@ type cleanTarget struct { path string } +type cleanTargetReport struct { + Label string `json:"label"` + Path string `json:"path"` + Exists bool `json:"exists"` + Bytes int64 `json:"bytes"` + Removed bool `json:"removed"` +} + +type cleanReport struct { + Forced bool `json:"forced"` + TotalBytes int64 `json:"total_bytes"` + PresentCount int `json:"present_count"` + Targets []cleanTargetReport `json:"targets"` +} + // cleanTargets returns the transient directories clean manages, rooted at // dataDir. Only regenerable state is included: saved sessions and watchdog // diagnostics. The user config file, installed extensions, crash reports @@ -54,50 +70,76 @@ clean never touches your config file, installed extensions, crash reports SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { force, _ := cmd.Flags().GetBool("force") - return runClean(cmd.OutOrStdout(), cleanTargets(dataDir()), force) + asJSON, _ := cmd.Flags().GetBool("json") + return runClean(cmd.OutOrStdout(), cleanTargets(dataDir()), force, asJSON) }, } cmd.Flags().Bool("force", false, "Delete the transient data (without this flag, clean only previews)") + cmd.Flags().Bool("json", false, "Print the clean report as JSON") return cmd } // runClean scans each target, then either previews it or removes it. A // missing target is reported and skipped rather than treated as an error, so // running clean on a fresh machine is a no-op. -func runClean(out io.Writer, targets []cleanTarget, force bool) error { - var total int64 - present := 0 +func runClean(out io.Writer, targets []cleanTarget, force, asJSON bool) error { + report := cleanReport{ + Forced: force, + Targets: make([]cleanTargetReport, 0, len(targets)), + } for _, t := range targets { size, exists, err := dirSize(t.path) if err != nil { return fmt.Errorf("scanning %s: %w", t.label, err) } + targetReport := cleanTargetReport{ + Label: t.label, + Path: t.path, + Exists: exists, + Bytes: size, + } if !exists { - fmt.Fprintf(out, " %-12s not present\n", t.label) + report.Targets = append(report.Targets, targetReport) + if !asJSON { + fmt.Fprintf(out, " %-12s not present\n", t.label) + } continue } - present++ - total += size + report.PresentCount++ + report.TotalBytes += size if force { if err := os.RemoveAll(t.path); err != nil { return fmt.Errorf("removing %s: %w", t.label, err) } - fmt.Fprintf(out, " %-12s removed %s\n", t.label, humanizeBytes(size)) + targetReport.Removed = true + report.Targets = append(report.Targets, targetReport) + if !asJSON { + fmt.Fprintf(out, " %-12s removed %s\n", t.label, humanizeBytes(size)) + } continue } - fmt.Fprintf(out, " %-12s %-10s %s\n", t.label, humanizeBytes(size), t.path) + report.Targets = append(report.Targets, targetReport) + if !asJSON { + fmt.Fprintf(out, " %-12s %-10s %s\n", t.label, humanizeBytes(size), t.path) + } + } + + if asJSON { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(report) } fmt.Fprintln(out) if force { - fmt.Fprintf(out, "Reclaimed %s.\n", humanizeBytes(total)) + fmt.Fprintf(out, "Reclaimed %s.\n", humanizeBytes(report.TotalBytes)) return nil } - if present == 0 { + if report.PresentCount == 0 { fmt.Fprintln(out, "Nothing to clean.") return nil } - fmt.Fprintf(out, "%s across %d location(s). Run with --force to delete.\n", humanizeBytes(total), present) + fmt.Fprintf(out, "%s across %d location(s). Run with --force to delete.\n", humanizeBytes(report.TotalBytes), report.PresentCount) return nil } diff --git a/cmd/clean_test.go b/cmd/clean_test.go index 8b1c28aa..2a8eec7f 100644 --- a/cmd/clean_test.go +++ b/cmd/clean_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "encoding/json" "os" "path/filepath" "testing" @@ -100,6 +101,57 @@ func TestCleanMissingDirsForce(t *testing.T) { assert.Contains(t, out, "Reclaimed 0 B.") } +func TestCleanJSONPreviewReportsTargetsWithoutDeleting(t *testing.T) { + dataDir := seedCleanData(t) + + out := execClean(t, dataDir, "--json") + + var report cleanReport + require.NoError(t, json.Unmarshal([]byte(out), &report)) + assert.False(t, report.Forced) + assert.Equal(t, int64(len("session")+len("diag")), report.TotalBytes) + assert.Equal(t, 2, report.PresentCount) + require.Len(t, report.Targets, 2) + assert.Equal(t, "sessions", report.Targets[0].Label) + assert.True(t, report.Targets[0].Exists) + assert.False(t, report.Targets[0].Removed) + assert.DirExists(t, filepath.Join(dataDir, "sessions")) + assert.DirExists(t, filepath.Join(dataDir, "diagnostics")) +} + +func TestCleanJSONForceReportsRemovedTargets(t *testing.T) { + dataDir := seedCleanData(t) + + out := execClean(t, dataDir, "--force", "--json") + + var report cleanReport + require.NoError(t, json.Unmarshal([]byte(out), &report)) + assert.True(t, report.Forced) + assert.Equal(t, 2, report.PresentCount) + for _, target := range report.Targets { + assert.True(t, target.Exists) + assert.True(t, target.Removed) + } + assert.NoDirExists(t, filepath.Join(dataDir, "sessions")) + assert.NoDirExists(t, filepath.Join(dataDir, "diagnostics")) +} + +func TestCleanJSONMissingDirs(t *testing.T) { + dataDir := t.TempDir() + + out := execClean(t, dataDir, "--json") + + var report cleanReport + require.NoError(t, json.Unmarshal([]byte(out), &report)) + assert.Equal(t, int64(0), report.TotalBytes) + assert.Equal(t, 0, report.PresentCount) + require.Len(t, report.Targets, 2) + for _, target := range report.Targets { + assert.False(t, target.Exists) + assert.False(t, target.Removed) + } +} + func TestCleanRejectsArgs(t *testing.T) { cmd := newCleanCmdWithDeps(func() string { return t.TempDir() }) cmd.SetOut(&bytes.Buffer{})