Skip to content

Commit a5b270a

Browse files
cpcloudclaude
andcommitted
feat(data): add TTL-based document cache eviction and review fixes
Add configurable cache eviction that removes extracted document files older than cache_ttl_days (default 30) on startup. Controlled via config file or MICASA_CACHE_TTL_DAYS env var; set to 0 to disable. Also addresses review feedback on the audit fix commit: - Remove unreachable WarrantyExpiry nil guard in dashExpiringRows (filtered by SQL WHERE IS NOT NULL + loadDashboardAt nil check) - Add compile-time test ensuring form data structs have no reference fields (cloneFormData shallow-copy safety) - Strengthen openFileCmd trust-boundary comment - Fix gosec G204 nolint placement in main_test.go so directives are on the exec.Command line, not a detached comment - Fix EvictStaleCache to accept dir param for proper test isolation (xdg.CacheHome is set at init, not affected by t.Setenv) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 15929fd commit a5b270a

File tree

10 files changed

+298
-12
lines changed

10 files changed

+298
-12
lines changed

cmd/micasa/main.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ func main() {
6666
if err := store.SetMaxDocumentSize(cfg.Documents.MaxFileSize); err != nil {
6767
fail("configure document size limit", err)
6868
}
69+
cacheDir, err := data.DocumentCacheDir()
70+
if err != nil {
71+
fail("resolve document cache directory", err)
72+
}
73+
if _, err := data.EvictStaleCache(cacheDir, cfg.Documents.CacheTTLDays); err != nil {
74+
fail("evict stale cache", err)
75+
}
6976

7077
opts := app.Options{
7178
DBPath: dbPath,

cmd/micasa/main_test.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,13 @@ func buildTestBinary(t *testing.T) string {
8282
ext = ".exe"
8383
}
8484
bin := filepath.Join(t.TempDir(), "micasa"+ext)
85-
cmd := exec.Command("go", "build", "-o", bin, ".")
85+
cmd := exec.Command( //nolint:gosec // test helper with constant args
86+
"go",
87+
"build",
88+
"-o",
89+
bin,
90+
".",
91+
)
8692
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
8793
out, err := cmd.CombinedOutput()
8894
require.NoError(t, err, "build failed:\n%s", out)
@@ -96,7 +102,8 @@ func TestVersion_DevShowsCommitHash(t *testing.T) {
96102
t.Skip("no .git directory; VCS info unavailable (e.g. Nix sandbox)")
97103
}
98104
bin := buildTestBinary(t)
99-
out, err := exec.Command(bin, "--version").Output()
105+
verCmd := exec.Command(bin, "--version") //nolint:gosec // test binary path from buildTestBinary
106+
out, err := verCmd.Output()
100107
require.NoError(t, err, "--version failed")
101108
got := strings.TrimSpace(string(out))
102109
// Built inside a git repo: expect a hex hash, possibly with -dirty.
@@ -110,13 +117,14 @@ func TestVersion_Injected(t *testing.T) {
110117
ext = ".exe"
111118
}
112119
bin := filepath.Join(t.TempDir(), "micasa"+ext)
113-
cmd := exec.Command("go", "build",
120+
cmd := exec.Command("go", "build", //nolint:gosec // test with constant args
114121
"-ldflags", "-X main.version=1.2.3",
115122
"-o", bin, ".")
116123
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
117124
out, err := cmd.CombinedOutput()
118125
require.NoError(t, err, "build failed:\n%s", out)
119-
verOut, err := exec.Command(bin, "--version").Output()
126+
verCmd := exec.Command(bin, "--version") //nolint:gosec // test binary path from above
127+
verOut, err := verCmd.Output()
120128
require.NoError(t, err, "--version failed")
121129
assert.Equal(t, "1.2.3", strings.TrimSpace(string(verOut)))
122130
}

internal/app/dashboard.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -671,10 +671,10 @@ func (m *Model) dashProjectRows() []dashRow {
671671
func (m *Model) dashExpiringRows() []dashRow {
672672
d := m.dashboard
673673
var rows []dashRow
674+
// WarrantyExpiry is guaranteed non-nil here: ListExpiringWarranties uses
675+
// WHERE warranty_expiry IS NOT NULL, and loadDashboardAt skips nil entries
676+
// before populating ExpiringWarranties.
674677
for _, w := range d.ExpiringWarranties {
675-
if w.Appliance.WarrantyExpiry == nil {
676-
continue
677-
}
678678
overdue := w.DaysFromNow < 0
679679
nameStyle := m.styles.DashUpcoming
680680
if overdue {

internal/app/dashboard_load_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,23 @@ func TestLoadDashboardAtBuildsNav(t *testing.T) {
193193
assert.NotEmpty(t, m.dashNav)
194194
assert.Equal(t, tabMaintenance, m.dashNav[0].Tab)
195195
}
196+
197+
func TestLoadDashboardExcludesAppliancesWithoutWarranty(t *testing.T) {
198+
m := newTestModelWithStore(t)
199+
200+
// One appliance with warranty in range, one without any warranty.
201+
expiry := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
202+
require.NoError(t, m.store.CreateAppliance(data.Appliance{
203+
Name: "Fridge",
204+
WarrantyExpiry: &expiry,
205+
}))
206+
require.NoError(t, m.store.CreateAppliance(data.Appliance{
207+
Name: "Toaster",
208+
}))
209+
210+
now := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
211+
require.NoError(t, m.loadDashboardAt(now))
212+
213+
require.Len(t, m.dashboard.ExpiringWarranties, 1)
214+
assert.Equal(t, "Fridge", m.dashboard.ExpiringWarranties[0].Appliance.Name)
215+
}

internal/app/docopen.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ func (m *Model) openSelectedDocument() tea.Cmd {
4141
// openFileCmd returns a tea.Cmd that opens the given path with the OS viewer.
4242
// The command runs to completion so exit-status errors (e.g. no handler for
4343
// the MIME type) are captured and returned as an openFileResultMsg.
44+
//
45+
// Only called from openSelectedDocument with a path returned by
46+
// Store.ExtractDocument (always under the XDG cache directory).
4447
func openFileCmd(path string) tea.Cmd {
4548
return func() tea.Msg {
4649
var cmd *exec.Cmd

internal/app/form_validators_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package app
55

66
import (
7+
"reflect"
78
"testing"
89
"time"
910

@@ -214,3 +215,63 @@ func TestServiceLogFormValuesNoVendor(t *testing.T) {
214215
assert.Zero(t, got.VendorID)
215216
assert.Empty(t, got.Cost)
216217
}
218+
219+
func TestFormDirtyDetectionUserFlow(t *testing.T) {
220+
m := newTestModel()
221+
222+
// Simulate: user opens an appliance edit form with pre-filled values.
223+
values := &applianceFormData{
224+
Name: "Fridge",
225+
Brand: "Samsung",
226+
Cost: "$899.00",
227+
}
228+
m.formData = values
229+
m.snapshotForm()
230+
231+
// User hasn't changed anything yet — form should not be dirty.
232+
m.checkFormDirty()
233+
assert.False(t, m.formDirty, "form should not be dirty before any edits")
234+
235+
// User edits the brand field.
236+
values.Brand = "LG"
237+
m.checkFormDirty()
238+
assert.True(t, m.formDirty, "form should be dirty after editing a field")
239+
240+
// User reverts the edit back to the original value.
241+
values.Brand = "Samsung"
242+
m.checkFormDirty()
243+
assert.False(t, m.formDirty, "form should not be dirty after reverting")
244+
}
245+
246+
// TestFormDataStructsHaveNoReferenceFields ensures cloneFormData's shallow
247+
// copy is safe. If any form data struct gains a pointer, slice, or map
248+
// field, this test will catch it -- the snapshot would share that reference
249+
// and dirty-detection via reflect.DeepEqual would silently break.
250+
func TestFormDataStructsHaveNoReferenceFields(t *testing.T) {
251+
structs := []any{
252+
projectFormData{},
253+
applianceFormData{},
254+
maintenanceFormData{},
255+
vendorFormData{},
256+
quoteFormData{},
257+
serviceLogFormData{},
258+
documentFormData{},
259+
houseFormData{},
260+
}
261+
for _, s := range structs {
262+
rt := reflect.TypeOf(s)
263+
t.Run(rt.Name(), func(t *testing.T) {
264+
for i := range rt.NumField() {
265+
f := rt.Field(i)
266+
switch f.Type.Kind() { //nolint:exhaustive // only reference kinds matter here
267+
case reflect.Ptr, reflect.Slice, reflect.Map,
268+
reflect.Chan, reflect.Func, reflect.Interface:
269+
t.Errorf(
270+
"field %s.%s is %s -- cloneFormData requires value-only fields",
271+
rt.Name(), f.Name, f.Type.Kind(),
272+
)
273+
}
274+
}
275+
})
276+
}
277+
}

internal/config/config.go

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,18 @@ type Documents struct {
4444
// MaxFileSize is the largest file (in bytes) that can be imported as a
4545
// document attachment. Default: 50 MiB.
4646
MaxFileSize int64 `toml:"max_file_size"`
47+
48+
// CacheTTLDays is the number of days an extracted document cache entry
49+
// is kept before being evicted on the next startup. Set to 0 to disable
50+
// eviction. Default: 30.
51+
CacheTTLDays int `toml:"cache_ttl_days"`
4752
}
4853

4954
const (
50-
DefaultBaseURL = "http://localhost:11434/v1"
51-
DefaultModel = "qwen3"
52-
configRelPath = "micasa/config.toml"
55+
DefaultBaseURL = "http://localhost:11434/v1"
56+
DefaultModel = "qwen3"
57+
DefaultCacheTTLDays = 30
58+
configRelPath = "micasa/config.toml"
5359
)
5460

5561
// defaults returns a Config with all default values populated.
@@ -60,7 +66,8 @@ func defaults() Config {
6066
Model: DefaultModel,
6167
},
6268
Documents: Documents{
63-
MaxFileSize: data.MaxDocumentSize,
69+
MaxFileSize: data.MaxDocumentSize,
70+
CacheTTLDays: DefaultCacheTTLDays,
6471
},
6572
}
6673
}
@@ -101,6 +108,13 @@ func LoadFromPath(path string) (Config, error) {
101108
)
102109
}
103110

111+
if cfg.Documents.CacheTTLDays < 0 {
112+
return cfg, fmt.Errorf(
113+
"documents.cache_ttl_days must be non-negative, got %d",
114+
cfg.Documents.CacheTTLDays,
115+
)
116+
}
117+
104118
return cfg, nil
105119
}
106120

@@ -123,6 +137,11 @@ func applyEnvOverrides(cfg *Config) {
123137
cfg.Documents.MaxFileSize = n
124138
}
125139
}
140+
if ttl := os.Getenv("MICASA_CACHE_TTL_DAYS"); ttl != "" {
141+
if n, err := strconv.Atoi(ttl); err == nil {
142+
cfg.Documents.CacheTTLDays = n
143+
}
144+
}
126145
}
127146

128147
// ExampleTOML returns a commented config file suitable for writing as a
@@ -148,5 +167,9 @@ model = "` + DefaultModel + `"
148167
[documents]
149168
# Maximum file size (in bytes) for document imports. Default: 50 MiB.
150169
# max_file_size = 52428800
170+
171+
# Days to keep extracted document cache entries before evicting on startup.
172+
# Set to 0 to disable eviction. Default: 30.
173+
# cache_ttl_days = 30
151174
`
152175
}

internal/config/config_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,37 @@ func TestMaxDocumentSizeRejectsInvalid(t *testing.T) {
137137
})
138138
}
139139
}
140+
141+
func TestDefaultCacheTTLDays(t *testing.T) {
142+
cfg, err := LoadFromPath(filepath.Join(t.TempDir(), "nope.toml"))
143+
require.NoError(t, err)
144+
assert.Equal(t, DefaultCacheTTLDays, cfg.Documents.CacheTTLDays)
145+
}
146+
147+
func TestCacheTTLDaysFromFile(t *testing.T) {
148+
path := writeConfig(t, "[documents]\ncache_ttl_days = 7\n")
149+
cfg, err := LoadFromPath(path)
150+
require.NoError(t, err)
151+
assert.Equal(t, 7, cfg.Documents.CacheTTLDays)
152+
}
153+
154+
func TestCacheTTLDaysZeroDisables(t *testing.T) {
155+
path := writeConfig(t, "[documents]\ncache_ttl_days = 0\n")
156+
cfg, err := LoadFromPath(path)
157+
require.NoError(t, err)
158+
assert.Equal(t, 0, cfg.Documents.CacheTTLDays)
159+
}
160+
161+
func TestCacheTTLDaysEnvOverride(t *testing.T) {
162+
t.Setenv("MICASA_CACHE_TTL_DAYS", "14")
163+
cfg, err := LoadFromPath(filepath.Join(t.TempDir(), "nope.toml"))
164+
require.NoError(t, err)
165+
assert.Equal(t, 14, cfg.Documents.CacheTTLDays)
166+
}
167+
168+
func TestCacheTTLDaysRejectsNegative(t *testing.T) {
169+
path := writeConfig(t, "[documents]\ncache_ttl_days = -1\n")
170+
_, err := LoadFromPath(path)
171+
require.Error(t, err)
172+
assert.Contains(t, err.Error(), "must be non-negative")
173+
}

internal/data/doccache.go

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
package data
55

66
import (
7+
"errors"
78
"fmt"
89
"os"
910
"path/filepath"
11+
"time"
1012
)
1113

1214
// ExtractDocument writes the document's BLOB content to the XDG cache
@@ -31,8 +33,15 @@ func (s *Store) ExtractDocument(id uint) (string, error) {
3133
name := doc.ChecksumSHA256 + "-" + filepath.Base(doc.FileName)
3234
cachePath := filepath.Join(cacheDir, name)
3335

34-
// Cache hit: file exists with correct size.
36+
// Cache hit: file exists with correct size. Touch the ModTime so the
37+
// TTL-based eviction in EvictStaleCache treats it as recently used.
3538
if info, statErr := os.Stat(cachePath); statErr == nil && info.Size() == doc.SizeBytes {
39+
now := time.Now()
40+
_ = os.Chtimes(
41+
cachePath,
42+
now,
43+
now,
44+
) // best-effort; stale ModTime just means earlier re-extraction
3645
return cachePath, nil
3746
}
3847

@@ -41,3 +50,39 @@ func (s *Store) ExtractDocument(id uint) (string, error) {
4150
}
4251
return cachePath, nil
4352
}
53+
54+
// EvictStaleCache removes cached document files from dir that haven't been
55+
// modified in the given number of days. A ttlDays of 0 disables eviction.
56+
// Returns the number of files removed and any error encountered while listing
57+
// the directory (individual file removal errors are skipped).
58+
func EvictStaleCache(dir string, ttlDays int) (int, error) {
59+
if ttlDays <= 0 || dir == "" {
60+
return 0, nil
61+
}
62+
63+
entries, err := os.ReadDir(dir)
64+
if err != nil {
65+
if errors.Is(err, os.ErrNotExist) {
66+
return 0, nil // cache dir doesn't exist yet; nothing to evict
67+
}
68+
return 0, fmt.Errorf("list cache dir: %w", err)
69+
}
70+
71+
cutoff := time.Now().AddDate(0, 0, -ttlDays)
72+
removed := 0
73+
for _, entry := range entries {
74+
if entry.IsDir() {
75+
continue
76+
}
77+
info, err := entry.Info()
78+
if err != nil {
79+
continue
80+
}
81+
if info.ModTime().Before(cutoff) {
82+
if os.Remove(filepath.Join(dir, entry.Name())) == nil {
83+
removed++
84+
}
85+
}
86+
}
87+
return removed, nil
88+
}

0 commit comments

Comments
 (0)