detached d86a9a1

feat(purge): add domain entity, ports, and use case

commit
d86a9a19edadc32961a01d905c7f8a7a9f2e89b8
author
brice · · 2025-12-22 06:12 UTC
parent
03f2d9a
signature
unsigned
4 files · +245 -0
A internal/application/port/filesystem.go +11 -0
@@ -0,0 +1,11 @@
1+package port
2+
3+import "context"
4+
5+// FileSystem provides file system operations for the application layer.
6+type FileSystem interface {
7+ Exists(ctx context.Context, path string) (bool, error)
8+ IsDirectory(ctx context.Context, path string) (bool, error)
9+ GetSize(ctx context.Context, path string) (int64, error)
10+ RemoveAll(ctx context.Context, path string) error
11+}
@@ -0,0 +1,11 @@
1+package port
2+
3+import "context"
4+
5+// FileSystem provides file system operations for the application layer.
6+type FileSystem interface {
7+ Exists(ctx context.Context, path string) (bool, error)
8+ IsDirectory(ctx context.Context, path string) (bool, error)
9+ GetSize(ctx context.Context, path string) (int64, error)
10+ RemoveAll(ctx context.Context, path string) error
11+}
A internal/application/port/xdg.go +13 -0
@@ -0,0 +1,13 @@
1+package port
2+
3+// XDGPaths provides XDG Base Directory paths.
4+type XDGPaths interface {
5+ ConfigDir() (string, error)
6+ DataDir() (string, error)
7+ StateDir() (string, error)
8+ CacheDir() (string, error)
9+
10+ FilterJSONDir() (string, error)
11+ FilterStoreDir() (string, error)
12+ FilterCacheDir() (string, error)
13+}
@@ -0,0 +1,13 @@
1+package port
2+
3+// XDGPaths provides XDG Base Directory paths.
4+type XDGPaths interface {
5+ ConfigDir() (string, error)
6+ DataDir() (string, error)
7+ StateDir() (string, error)
8+ CacheDir() (string, error)
9+
10+ FilterJSONDir() (string, error)
11+ FilterStoreDir() (string, error)
12+ FilterCacheDir() (string, error)
13+}
A internal/application/usecase/purge_data.go +189 -0
@@ -0,0 +1,189 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// PurgeDataUseCase handles discovering and purging application data.
13+type PurgeDataUseCase struct {
14+ fs port.FileSystem
15+ xdg port.XDGPaths
16+ desktop port.DesktopIntegration
17+}
18+
19+// NewPurgeDataUseCase creates a new PurgeDataUseCase.
20+func NewPurgeDataUseCase(fs port.FileSystem, xdg port.XDGPaths, desktop port.DesktopIntegration) *PurgeDataUseCase {
21+ return &PurgeDataUseCase{fs: fs, xdg: xdg, desktop: desktop}
22+}
23+
24+// GetPurgeTargets returns all available purge targets with their current state.
25+func (uc *PurgeDataUseCase) GetPurgeTargets(ctx context.Context) ([]entity.PurgeTarget, error) {
26+ configDir, err := uc.xdg.ConfigDir()
27+ if err != nil {
28+ return nil, err
29+ }
30+ dataDir, err := uc.xdg.DataDir()
31+ if err != nil {
32+ return nil, err
33+ }
34+ stateDir, err := uc.xdg.StateDir()
35+ if err != nil {
36+ return nil, err
37+ }
38+ cacheDir, err := uc.xdg.CacheDir()
39+ if err != nil {
40+ return nil, err
41+ }
42+ filterJSONDir, err := uc.xdg.FilterJSONDir()
43+ if err != nil {
44+ return nil, err
45+ }
46+ filterStoreDir, err := uc.xdg.FilterStoreDir()
47+ if err != nil {
48+ return nil, err
49+ }
50+ filterCacheDir, err := uc.xdg.FilterCacheDir()
51+ if err != nil {
52+ return nil, err
53+ }
54+
55+ baseTargets := []entity.PurgeTarget{
56+ {Type: entity.PurgeTargetConfig, Path: configDir, Description: "config"},
57+ {Type: entity.PurgeTargetData, Path: dataDir, Description: "data"},
58+ {Type: entity.PurgeTargetState, Path: stateDir, Description: "state"},
59+ {Type: entity.PurgeTargetCache, Path: cacheDir, Description: "cache"},
60+ {Type: entity.PurgeTargetFilterJSON, Path: filterJSONDir, Description: "filter JSON cache"},
61+ {Type: entity.PurgeTargetFilterStore, Path: filterStoreDir, Description: "compiled filters"},
62+ {Type: entity.PurgeTargetFilterCache, Path: filterCacheDir, Description: "filter cache"},
63+ }
64+
65+ status, statusErr := uc.desktop.GetStatus(ctx)
66+ if statusErr != nil {
67+ // Desktop status should not block purge targets discovery.
68+ logging.FromContext(ctx).Warn().Err(statusErr).Msg("failed to get desktop integration status")
69+ }
70+
71+ if status != nil {
72+ baseTargets = append(baseTargets,
73+ entity.PurgeTarget{Type: entity.PurgeTargetDesktopFile, Path: status.DesktopFilePath, Description: "desktop file"},
74+ entity.PurgeTarget{Type: entity.PurgeTargetIcon, Path: status.IconFilePath, Description: "icon"},
75+ )
76+ }
77+
78+ var targets []entity.PurgeTarget
79+ for _, t := range baseTargets {
80+ if t.Path == "" {
81+ t.Exists = false
82+ t.Size = 0
83+ targets = append(targets, t)
84+ continue
85+ }
86+ exists, err := uc.fs.Exists(ctx, t.Path)
87+ if err != nil {
88+ return nil, err
89+ }
90+ t.Exists = exists
91+ if !exists {
92+ t.Size = 0
93+ targets = append(targets, t)
94+ continue
95+ }
96+ size, err := uc.fs.GetSize(ctx, t.Path)
97+ if err != nil {
98+ return nil, err
99+ }
100+ t.Size = size
101+ targets = append(targets, t)
102+ }
103+
104+ return targets, nil
105+}
106+
107+// PurgeInput specifies which target types to purge.
108+type PurgeInput struct {
109+ TargetTypes []entity.PurgeTargetType
110+}
111+
112+// PurgeOutput contains the results of the purge operation.
113+type PurgeOutput struct {
114+ Results []entity.PurgeResult
115+ TotalSize int64
116+ SuccessCount int
117+ FailureCount int
118+}
119+
120+// Execute purges the selected target types.
121+// Continues on errors, collecting all results.
122+func (uc *PurgeDataUseCase) Execute(ctx context.Context, input PurgeInput) (*PurgeOutput, error) {
123+ log := logging.FromContext(ctx)
124+
125+ targets, err := uc.GetPurgeTargets(ctx)
126+ if err != nil {
127+ return nil, err
128+ }
129+
130+ selected := make(map[entity.PurgeTargetType]struct{}, len(input.TargetTypes))
131+ for _, tt := range input.TargetTypes {
132+ selected[tt] = struct{}{}
133+ }
134+
135+ out := &PurgeOutput{}
136+ for _, t := range targets {
137+ if _, ok := selected[t.Type]; !ok {
138+ continue
139+ }
140+ if !t.Exists {
141+ continue
142+ }
143+
144+ res := entity.PurgeResult{Target: t}
145+ out.TotalSize += t.Size
146+
147+ switch t.Type {
148+ case entity.PurgeTargetDesktopFile:
149+ err = uc.desktop.RemoveDesktopFile(ctx)
150+ case entity.PurgeTargetIcon:
151+ err = uc.desktop.RemoveIcon(ctx)
152+ default:
153+ err = uc.fs.RemoveAll(ctx, t.Path)
154+ }
155+
156+ if err != nil {
157+ res.Success = false
158+ res.Error = err
159+ out.FailureCount++
160+ log.Warn().Err(err).Str("path", t.Path).Int("type", int(t.Type)).Msg("purge target failed")
161+ } else {
162+ res.Success = true
163+ out.SuccessCount++
164+ log.Info().Str("path", t.Path).Int("type", int(t.Type)).Msg("purge target removed")
165+ }
166+
167+ out.Results = append(out.Results, res)
168+ }
169+
170+ if out.FailureCount > 0 {
171+ return out, fmt.Errorf("failed to remove %d items", out.FailureCount)
172+ }
173+ return out, nil
174+}
175+
176+// PurgeAll purges all existing targets (for --force mode).
177+func (uc *PurgeDataUseCase) PurgeAll(ctx context.Context) (*PurgeOutput, error) {
178+ targets, err := uc.GetPurgeTargets(ctx)
179+ if err != nil {
180+ return nil, err
181+ }
182+
183+ var types []entity.PurgeTargetType
184+ for _, t := range targets {
185+ types = append(types, t.Type)
186+ }
187+
188+ return uc.Execute(ctx, PurgeInput{TargetTypes: types})
189+}
@@ -0,0 +1,189 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// PurgeDataUseCase handles discovering and purging application data.
13+type PurgeDataUseCase struct {
14+ fs port.FileSystem
15+ xdg port.XDGPaths
16+ desktop port.DesktopIntegration
17+}
18+
19+// NewPurgeDataUseCase creates a new PurgeDataUseCase.
20+func NewPurgeDataUseCase(fs port.FileSystem, xdg port.XDGPaths, desktop port.DesktopIntegration) *PurgeDataUseCase {
21+ return &PurgeDataUseCase{fs: fs, xdg: xdg, desktop: desktop}
22+}
23+
24+// GetPurgeTargets returns all available purge targets with their current state.
25+func (uc *PurgeDataUseCase) GetPurgeTargets(ctx context.Context) ([]entity.PurgeTarget, error) {
26+ configDir, err := uc.xdg.ConfigDir()
27+ if err != nil {
28+ return nil, err
29+ }
30+ dataDir, err := uc.xdg.DataDir()
31+ if err != nil {
32+ return nil, err
33+ }
34+ stateDir, err := uc.xdg.StateDir()
35+ if err != nil {
36+ return nil, err
37+ }
38+ cacheDir, err := uc.xdg.CacheDir()
39+ if err != nil {
40+ return nil, err
41+ }
42+ filterJSONDir, err := uc.xdg.FilterJSONDir()
43+ if err != nil {
44+ return nil, err
45+ }
46+ filterStoreDir, err := uc.xdg.FilterStoreDir()
47+ if err != nil {
48+ return nil, err
49+ }
50+ filterCacheDir, err := uc.xdg.FilterCacheDir()
51+ if err != nil {
52+ return nil, err
53+ }
54+
55+ baseTargets := []entity.PurgeTarget{
56+ {Type: entity.PurgeTargetConfig, Path: configDir, Description: "config"},
57+ {Type: entity.PurgeTargetData, Path: dataDir, Description: "data"},
58+ {Type: entity.PurgeTargetState, Path: stateDir, Description: "state"},
59+ {Type: entity.PurgeTargetCache, Path: cacheDir, Description: "cache"},
60+ {Type: entity.PurgeTargetFilterJSON, Path: filterJSONDir, Description: "filter JSON cache"},
61+ {Type: entity.PurgeTargetFilterStore, Path: filterStoreDir, Description: "compiled filters"},
62+ {Type: entity.PurgeTargetFilterCache, Path: filterCacheDir, Description: "filter cache"},
63+ }
64+
65+ status, statusErr := uc.desktop.GetStatus(ctx)
66+ if statusErr != nil {
67+ // Desktop status should not block purge targets discovery.
68+ logging.FromContext(ctx).Warn().Err(statusErr).Msg("failed to get desktop integration status")
69+ }
70+
71+ if status != nil {
72+ baseTargets = append(baseTargets,
73+ entity.PurgeTarget{Type: entity.PurgeTargetDesktopFile, Path: status.DesktopFilePath, Description: "desktop file"},
74+ entity.PurgeTarget{Type: entity.PurgeTargetIcon, Path: status.IconFilePath, Description: "icon"},
75+ )
76+ }
77+
78+ var targets []entity.PurgeTarget
79+ for _, t := range baseTargets {
80+ if t.Path == "" {
81+ t.Exists = false
82+ t.Size = 0
83+ targets = append(targets, t)
84+ continue
85+ }
86+ exists, err := uc.fs.Exists(ctx, t.Path)
87+ if err != nil {
88+ return nil, err
89+ }
90+ t.Exists = exists
91+ if !exists {
92+ t.Size = 0
93+ targets = append(targets, t)
94+ continue
95+ }
96+ size, err := uc.fs.GetSize(ctx, t.Path)
97+ if err != nil {
98+ return nil, err
99+ }
100+ t.Size = size
101+ targets = append(targets, t)
102+ }
103+
104+ return targets, nil
105+}
106+
107+// PurgeInput specifies which target types to purge.
108+type PurgeInput struct {
109+ TargetTypes []entity.PurgeTargetType
110+}
111+
112+// PurgeOutput contains the results of the purge operation.
113+type PurgeOutput struct {
114+ Results []entity.PurgeResult
115+ TotalSize int64
116+ SuccessCount int
117+ FailureCount int
118+}
119+
120+// Execute purges the selected target types.
121+// Continues on errors, collecting all results.
122+func (uc *PurgeDataUseCase) Execute(ctx context.Context, input PurgeInput) (*PurgeOutput, error) {
123+ log := logging.FromContext(ctx)
124+
125+ targets, err := uc.GetPurgeTargets(ctx)
126+ if err != nil {
127+ return nil, err
128+ }
129+
130+ selected := make(map[entity.PurgeTargetType]struct{}, len(input.TargetTypes))
131+ for _, tt := range input.TargetTypes {
132+ selected[tt] = struct{}{}
133+ }
134+
135+ out := &PurgeOutput{}
136+ for _, t := range targets {
137+ if _, ok := selected[t.Type]; !ok {
138+ continue
139+ }
140+ if !t.Exists {
141+ continue
142+ }
143+
144+ res := entity.PurgeResult{Target: t}
145+ out.TotalSize += t.Size
146+
147+ switch t.Type {
148+ case entity.PurgeTargetDesktopFile:
149+ err = uc.desktop.RemoveDesktopFile(ctx)
150+ case entity.PurgeTargetIcon:
151+ err = uc.desktop.RemoveIcon(ctx)
152+ default:
153+ err = uc.fs.RemoveAll(ctx, t.Path)
154+ }
155+
156+ if err != nil {
157+ res.Success = false
158+ res.Error = err
159+ out.FailureCount++
160+ log.Warn().Err(err).Str("path", t.Path).Int("type", int(t.Type)).Msg("purge target failed")
161+ } else {
162+ res.Success = true
163+ out.SuccessCount++
164+ log.Info().Str("path", t.Path).Int("type", int(t.Type)).Msg("purge target removed")
165+ }
166+
167+ out.Results = append(out.Results, res)
168+ }
169+
170+ if out.FailureCount > 0 {
171+ return out, fmt.Errorf("failed to remove %d items", out.FailureCount)
172+ }
173+ return out, nil
174+}
175+
176+// PurgeAll purges all existing targets (for --force mode).
177+func (uc *PurgeDataUseCase) PurgeAll(ctx context.Context) (*PurgeOutput, error) {
178+ targets, err := uc.GetPurgeTargets(ctx)
179+ if err != nil {
180+ return nil, err
181+ }
182+
183+ var types []entity.PurgeTargetType
184+ for _, t := range targets {
185+ types = append(types, t.Type)
186+ }
187+
188+ return uc.Execute(ctx, PurgeInput{TargetTypes: types})
189+}
A internal/domain/entity/purge.go +32 -0
@@ -0,0 +1,32 @@
1+package entity
2+
3+// PurgeTargetType identifies what kind of purgeable item this is.
4+type PurgeTargetType int
5+
6+const (
7+ PurgeTargetConfig PurgeTargetType = iota
8+ PurgeTargetData
9+ PurgeTargetState
10+ PurgeTargetCache
11+ PurgeTargetFilterJSON
12+ PurgeTargetFilterStore
13+ PurgeTargetFilterCache
14+ PurgeTargetDesktopFile
15+ PurgeTargetIcon
16+)
17+
18+// PurgeTarget represents something that can be purged.
19+type PurgeTarget struct {
20+ Type PurgeTargetType
21+ Path string
22+ Description string
23+ Size int64
24+ Exists bool
25+}
26+
27+// PurgeResult represents the outcome of purging a single target.
28+type PurgeResult struct {
29+ Target PurgeTarget
30+ Success bool
31+ Error error
32+}
@@ -0,0 +1,32 @@
1+package entity
2+
3+// PurgeTargetType identifies what kind of purgeable item this is.
4+type PurgeTargetType int
5+
6+const (
7+ PurgeTargetConfig PurgeTargetType = iota
8+ PurgeTargetData
9+ PurgeTargetState
10+ PurgeTargetCache
11+ PurgeTargetFilterJSON
12+ PurgeTargetFilterStore
13+ PurgeTargetFilterCache
14+ PurgeTargetDesktopFile
15+ PurgeTargetIcon
16+)
17+
18+// PurgeTarget represents something that can be purged.
19+type PurgeTarget struct {
20+ Type PurgeTargetType
21+ Path string
22+ Description string
23+ Size int64
24+ Exists bool
25+}
26+
27+// PurgeResult represents the outcome of purging a single target.
28+type PurgeResult struct {
29+ Target PurgeTarget
30+ Success bool
31+ Error error
32+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close