aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/cli.go51
-rw-r--r--internal/cli/cli_test.go63
-rw-r--r--internal/liturgy/clean_test.go72
-rw-r--r--internal/liturgy/fetch.go48
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)