diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/cli/cli.go | 51 | ||||
| -rw-r--r-- | internal/cli/cli_test.go | 63 | ||||
| -rw-r--r-- | internal/liturgy/clean_test.go | 72 | ||||
| -rw-r--r-- | internal/liturgy/fetch.go | 48 |
4 files changed, 233 insertions, 1 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go index a13f2f1..5c05713 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -39,6 +39,7 @@ Flags: -l, --lectionary WHICH new|trad (trad -> traditional) -g, --lang LANG traditional lectionary language: pl|en -u, --update harvest sigla maximally to the horizon (idempotent) + -C, --clean prune cached readings older than a year -v, --version print the version and exit -h, --help this help @@ -97,7 +98,7 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 2 } - var all, raw, refresh, offline, update bool + var all, raw, refresh, offline, update, clean bool var bibleVer, compareList, lectionary, lang string var width int @@ -125,6 +126,8 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { fs.StringVar(&lang, "lang", "", "pl|en") fs.BoolVar(&update, "u", false, "harvest sigla maximally to the horizon") fs.BoolVar(&update, "update", false, "harvest sigla maximally to the horizon") + fs.BoolVar(&clean, "C", false, "prune cached readings older than a year") + fs.BoolVar(&clean, "clean", false, "prune cached readings older than a year") if err := fs.Parse(rest); err != nil { return 2 @@ -144,6 +147,9 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 2 } + if clean { + return runClean(stdout, stderr) + } if update { return runHarvest(date, stdout, stderr) } @@ -247,6 +253,49 @@ func runHarvest(date string, stdout, stderr io.Writer) int { return 0 } +// runClean handles -C/--clean: prune cached readings older than one year +// (relative to today()) and print a human-readable summary of what was +// removed. Like -u/--update, this is a maintenance mode -- it ignores DATE +// and every render flag, and is dispatched before the render paths. If both +// --clean and -u/--update are given, --clean takes precedence (see Run). +func runClean(stdout, stderr io.Writer) int { + now, err := time.Parse("2006-01-02", today()) + if err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 1 + } + before := now.AddDate(-1, 0, 0) + + removed, freed, err := liturgy.CleanCache(before) + if err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 1 + } + + cutoff := before.Format("2006-01-02") + if removed == 0 { + fmt.Fprintf(stdout, "cache already clean (nothing older than %s)\n", cutoff) + return 0 + } + entries := "entries" + if removed == 1 { + entries = "entry" + } + fmt.Fprintf(stdout, "cleaned %d cache %s older than %s (freed %s)\n", removed, entries, cutoff, formatFreed(freed)) + return 0 +} + +// formatFreed renders a byte count the way -C/--clean's summary line wants +// it: megabytes with one decimal once it's a meaningful size, kilobytes +// (also one decimal) for anything smaller. +func formatFreed(bytes int64) string { + const mb = 1024 * 1024 + if bytes >= mb { + return fmt.Sprintf("%.1f MB", float64(bytes)/mb) + } + return fmt.Sprintf("%.1f KB", float64(bytes)/1024) +} + // fetchAndPrint is the shared single-version render path (default version or // -b/--bible): fetch via the readings router, apply the offline version // swap, then render each section's heading and render.GatherVersion blocks. diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index d0c8791..5004af4 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -2,6 +2,8 @@ package cli import ( "bytes" + "os" + "path/filepath" "strings" "testing" ) @@ -126,6 +128,67 @@ func TestBannerForLang(t *testing.T) { } } +// TestCleanEmptyCache exercises -C/--clean's dispatch as a maintenance mode: +// it must be handled before any render path (and before the network-hitting +// -u/--update path) is reached. Pointing XDG_CACHE_HOME at an empty temp dir +// keeps liturgy.CleanCache's directory-read hermetic (no network), and an +// empty dir exercises the "nothing to remove" branch of the summary line. +func TestCleanEmptyCache(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + var out, errb bytes.Buffer + code := Run([]string{"-C"}, nil, &out, &errb) + if code != 0 { + t.Fatalf("--clean code=%d want 0 (stderr=%q)", code, errb.String()) + } + if !strings.Contains(out.String(), "clean") { + t.Errorf("--clean stdout=%q, want a clean/nothing message", out.String()) + } +} + +// TestCleanRemovesOldEntries exercises the non-empty summary branch: a stale +// cache pair sitting in XDG_CACHE_HOME must be reported as removed, entirely +// via the filesystem (no network involved). +func TestCleanRemovesOldEntries(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", dir) + cacheDir := filepath.Join(dir, "lectio") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cacheDir, "2020-01-01.html"), []byte("<html>old</html>"), 0o644); err != nil { + t.Fatal(err) + } + + var out, errb bytes.Buffer + code := Run([]string{"--clean"}, nil, &out, &errb) + if code != 0 { + t.Fatalf("--clean code=%d want 0 (stderr=%q)", code, errb.String()) + } + if !strings.Contains(out.String(), "cleaned 1 cache entry") { + t.Errorf("--clean stdout=%q, want a \"cleaned 1 cache entry\" message", out.String()) + } + if _, err := os.Stat(filepath.Join(cacheDir, "2020-01-01.html")); !os.IsNotExist(err) { + t.Errorf("2020-01-01.html still exists after --clean") + } +} + +// TestCleanPreferredOverUpdate exercises Run's stated precedence: when both +// --clean and -u/--update are given, --clean wins and -u's network-hitting +// harvest path is never reached (proven here by the empty-cache dir plus a +// zero exit code -- a network attempt against no test server would either +// hang or return an error/non-zero code). +func TestCleanPreferredOverUpdate(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + var out, errb bytes.Buffer + code := Run([]string{"--clean", "-u"}, nil, &out, &errb) + if code != 0 { + t.Fatalf("--clean -u code=%d want 0 (stderr=%q)", code, errb.String()) + } + if !strings.Contains(out.String(), "clean") { + t.Errorf("--clean -u stdout=%q, want a clean/nothing message", out.String()) + } +} + // TestDateTokenAnyPosition exercises extractDate directly: the date token // is found regardless of where it appears among other flags. func TestDateTokenAnyPosition(t *testing.T) { diff --git a/internal/liturgy/clean_test.go b/internal/liturgy/clean_test.go new file mode 100644 index 0000000..4dbe0d9 --- /dev/null +++ b/internal/liturgy/clean_test.go @@ -0,0 +1,72 @@ +package liturgy + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// TestCleanCache exercises CleanCache's file-selection rules: only +// "<YYYY-MM-DD>.html"/".json" pairs older than the cutoff are removed; a +// recent pair and a non-matching file are left untouched. The cutoff is a +// fixed literal (not time.Now-derived) so the test is deterministic; only +// the "recent" fixture is anchored to today, and only to make sure it lands +// safely on the "keep" side of that fixed cutoff. +func TestCleanCache(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", dir) + cacheDir := filepath.Join(dir, "lectio") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + t.Fatal(err) + } + + recent := time.Now().Format("2006-01-02") + files := map[string]string{ + "2020-01-01.html": "<html>old</html>", + "2020-01-01.json": `[{"Heading":"old"}]`, + recent + ".html": "<html>recent</html>", + "notes.txt": "not a cache file", + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(cacheDir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + cutoff := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + removed, freed, err := CleanCache(cutoff) + if err != nil { + t.Fatalf("CleanCache: %v", err) + } + if removed != 2 { + t.Errorf("removed = %d, want 2", removed) + } + if freed <= 0 { + t.Errorf("freed = %d, want > 0", freed) + } + + for _, gone := range []string{"2020-01-01.html", "2020-01-01.json"} { + if _, err := os.Stat(filepath.Join(cacheDir, gone)); !os.IsNotExist(err) { + t.Errorf("%s still exists after CleanCache, want removed", gone) + } + } + for _, kept := range []string{recent + ".html", "notes.txt"} { + if _, err := os.Stat(filepath.Join(cacheDir, kept)); err != nil { + t.Errorf("%s missing after CleanCache, want kept: %v", kept, err) + } + } +} + +// TestCleanCacheMissingDir checks the documented no-op: a cache dir that +// does not exist yet is not an error. +func TestCleanCacheMissingDir(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + removed, freed, err := CleanCache(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("CleanCache on missing dir: %v", err) + } + if removed != 0 || freed != 0 { + t.Errorf("CleanCache on missing dir = (%d, %d), want (0, 0)", removed, freed) + } +} diff --git a/internal/liturgy/fetch.go b/internal/liturgy/fetch.go index 32eaaad..f4f6992 100644 --- a/internal/liturgy/fetch.go +++ b/internal/liturgy/fetch.go @@ -41,6 +41,12 @@ var publishedRe = regexp.MustCompile(`id="\w*0all"`) // validate its own input first. var dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) +// cacheFileRe matches the cache file names Load/Harvest write into +// cacheDir(): "<YYYY-MM-DD>.html" or "<YYYY-MM-DD>.json". CleanCache uses it +// to tell cache entries apart from anything else that might be sitting in +// the directory. +var cacheFileRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})\.(html|json)$`) + // Options controls how Load resolves a day's readings. type Options struct { // Date is the day to load, formatted YYYY-MM-DD. @@ -135,6 +141,48 @@ func Load(opts Options) ([]Section, error) { return secs, nil } +// CleanCache removes cached readings whose date is before `before` from +// cacheDir(). It matches only files named "<YYYY-MM-DD>.html"/".json"; +// anything else in the directory (e.g. a stray notes.txt, or the sigla +// store, which lives elsewhere entirely) is left alone. A missing cache dir +// is not an error -- it just means there is nothing to clean yet. +func CleanCache(before time.Time) (removed int, freed int64, err error) { + dir := cacheDir() + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return 0, 0, nil + } + return 0, 0, err + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + m := cacheFileRe.FindStringSubmatch(entry.Name()) + if m == nil { + continue + } + date, perr := time.Parse("2006-01-02", m[1]) + if perr != nil || !date.Before(before) { + continue + } + + path := filepath.Join(dir, entry.Name()) + info, serr := os.Stat(path) + if serr != nil { + return removed, freed, serr + } + if rerr := os.Remove(path); rerr != nil { + return removed, freed, rerr + } + removed++ + freed += info.Size() + } + return removed, freed, nil +} + // loadJSONCache reads and unmarshals the parsed-sections cache file. func loadJSONCache(path string) ([]Section, error) { data, err := os.ReadFile(path) |
