Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions cmd/clean.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package cmd

import (
"fmt"
"io"
"io/fs"
"os"
"path/filepath"

"github.com/jongio/grut/internal/config"
"github.com/spf13/cobra"
)

// dataDirFunc resolves grut's data directory. It is injected so tests can
// point the clean command at a temporary directory instead of the real one.
type dataDirFunc func() string

// cleanTarget is a directory that "grut clean" is allowed to remove.
type cleanTarget struct {
label string
path string
}

// 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
// (owned by "grut report --clear"), the MCP audit log, and the first-run
// marker all live elsewhere under the data or config directory and are left
// untouched.
func cleanTargets(dataDir string) []cleanTarget {
return []cleanTarget{
{label: "sessions", path: filepath.Join(dataDir, "sessions")},
{label: "diagnostics", path: filepath.Join(dataDir, "diagnostics")},
}
}

func newCleanCmd() *cobra.Command {
return newCleanCmdWithDeps(config.DataDir)
}

func newCleanCmdWithDeps(dataDir dataDirFunc) *cobra.Command {
cmd := &cobra.Command{
Use: "clean",
Short: "Remove grut's transient session and diagnostic data",
Long: `Remove grut's transient data: saved session state and watchdog
diagnostic logs.

Without --force, clean previews what would be removed and how much disk
space it would reclaim. Pass --force to delete.

clean never touches your config file, installed extensions, crash reports
(use "grut report --clear" for those), or the MCP audit log.`,
Args: cobra.NoArgs,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
force, _ := cmd.Flags().GetBool("force")
return runClean(cmd.OutOrStdout(), cleanTargets(dataDir()), force)
},
}
cmd.Flags().Bool("force", false, "Delete the transient data (without this flag, clean only previews)")
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
for _, t := range targets {
size, exists, err := dirSize(t.path)
if err != nil {
return fmt.Errorf("scanning %s: %w", t.label, err)
}
if !exists {
fmt.Fprintf(out, " %-12s not present\n", t.label)
continue
}
present++
total += 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))
continue
}
fmt.Fprintf(out, " %-12s %-10s %s\n", t.label, humanizeBytes(size), t.path)
}

fmt.Fprintln(out)
if force {
fmt.Fprintf(out, "Reclaimed %s.\n", humanizeBytes(total))
return nil
}
if present == 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)
return nil
}

// dirSize returns the total size of all files under path. The second return
// value reports whether path exists; a missing path yields (0, false, nil).
func dirSize(path string) (int64, bool, error) {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return 0, false, nil
}
return 0, false, err
}
if !info.IsDir() {
return info.Size(), true, nil
}

var total int64
walkErr := filepath.WalkDir(path, func(_ string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
fi, err := d.Info()
if err != nil {
return err
}
total += fi.Size()
return nil
})
if walkErr != nil {
return 0, true, walkErr
}
return total, true, nil
}

// humanizeBytes formats a byte count using binary units (KiB, MiB, ...).
func humanizeBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for size := n / unit; size >= unit; size /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}
149 changes: 149 additions & 0 deletions cmd/clean_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package cmd

import (
"bytes"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// seedCleanData creates a data dir populated with the transient targets clean
// removes plus the excluded entries it must preserve. It returns the data dir
// path.
func seedCleanData(t *testing.T) string {
t.Helper()
dataDir := t.TempDir()

// Targets clean should remove.
writeFile(t, filepath.Join(dataDir, "sessions", "abc", "state.json"), "session")
writeFile(t, filepath.Join(dataDir, "diagnostics", "watchdog.log"), "diag")

// Entries clean must leave alone.
writeFile(t, filepath.Join(dataDir, "crashes", "crash-1.txt"), "crash")
writeFile(t, filepath.Join(dataDir, "extensions", "ext", "main.lua"), "ext")
writeFile(t, filepath.Join(dataDir, "mcp-audit.log"), "audit")
writeFile(t, filepath.Join(dataDir, ".first-run-done"), "")

return dataDir
}

func writeFile(t *testing.T, path, content string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
}

func execClean(t *testing.T, dataDir string, args ...string) string {
t.Helper()
cmd := newCleanCmdWithDeps(func() string { return dataDir })
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs(args)
require.NoError(t, cmd.Execute())
return out.String()
}

func TestCleanPreviewListsTargetsWithoutDeleting(t *testing.T) {
dataDir := seedCleanData(t)

out := execClean(t, dataDir)

assert.Contains(t, out, "sessions")
assert.Contains(t, out, "diagnostics")
assert.Contains(t, out, "Run with --force to delete.")

// Preview must not delete anything.
assert.DirExists(t, filepath.Join(dataDir, "sessions"))
assert.DirExists(t, filepath.Join(dataDir, "diagnostics"))
}

func TestCleanForceRemovesTargets(t *testing.T) {
dataDir := seedCleanData(t)

out := execClean(t, dataDir, "--force")

assert.Contains(t, out, "removed")
assert.Contains(t, out, "Reclaimed")
assert.NoDirExists(t, filepath.Join(dataDir, "sessions"))
assert.NoDirExists(t, filepath.Join(dataDir, "diagnostics"))
}

func TestCleanForcePreservesExcludedEntries(t *testing.T) {
dataDir := seedCleanData(t)

execClean(t, dataDir, "--force")

assert.FileExists(t, filepath.Join(dataDir, "crashes", "crash-1.txt"))
assert.FileExists(t, filepath.Join(dataDir, "extensions", "ext", "main.lua"))
assert.FileExists(t, filepath.Join(dataDir, "mcp-audit.log"))
assert.FileExists(t, filepath.Join(dataDir, ".first-run-done"))
}

func TestCleanMissingDirsPreview(t *testing.T) {
dataDir := t.TempDir() // nothing seeded

out := execClean(t, dataDir)

assert.Contains(t, out, "not present")
assert.Contains(t, out, "Nothing to clean.")
}

func TestCleanMissingDirsForce(t *testing.T) {
dataDir := t.TempDir()

out := execClean(t, dataDir, "--force")

assert.Contains(t, out, "Reclaimed 0 B.")
}

func TestCleanRejectsArgs(t *testing.T) {
cmd := newCleanCmdWithDeps(func() string { return t.TempDir() })
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"unexpected"})

require.Error(t, cmd.Execute())
}

func TestRootRegistersClean(t *testing.T) {
root, cleanup := newRootCommand()
defer cleanup()

cleanCmd, _, err := root.Find([]string{"clean"})
require.NoError(t, err)
require.NotNil(t, cleanCmd)
assert.Equal(t, "clean", cleanCmd.Name())
}

func TestDirSize(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "a.txt"), "hello") // 5 bytes
writeFile(t, filepath.Join(dir, "sub", "b.txt"), "world") // 5 bytes

size, exists, err := dirSize(dir)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, int64(10), size)

_, missing, err := dirSize(filepath.Join(dir, "nope"))
require.NoError(t, err)
assert.False(t, missing)
}

func TestHumanizeBytes(t *testing.T) {
cases := map[int64]string{
0: "0 B",
512: "512 B",
1024: "1.0 KiB",
1536: "1.5 KiB",
1024 * 1024: "1.0 MiB",
5 * 1024 * 1024: "5.0 MiB",
}
for in, want := range cases {
assert.Equal(t, want, humanizeBytes(in))
}
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ Environment:
rootCmd.AddCommand(newRunCmd())
rootCmd.AddCommand(newReportCmd())
rootCmd.AddCommand(newConfigCmd())
rootCmd.AddCommand(newCleanCmd())
rootCmd.AddCommand(newCompletionCmd())
rootCmd.AddCommand(newKeysCmd())

Expand Down
Loading