1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
|
# National Bibles Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let anyone add their nation's Bible as a `<code>.tsv` + `<code>.ini` pair — dropped into `~/.config/lectio/corpora/` at runtime or embedded via a validated Makefile step — so offline readings render in any language.
**Architecture:** A corpus is a file pair. Built-ins live in `internal/bible/corpora/` (glob-embedded); user corpora in `~/.config/lectio/corpora/` override built-ins of the same code. Reading-text selection is config-driven (`reading_version` > `reading_lang` match > `ui_language` match > Latin `vul`). One validator (`lectio --corpus-check`) gates both delivery paths.
**Tech Stack:** Go (stdlib + `internal/ini`), 6-column TSV, INI sidecars, `go:embed`, Makefile + POSIX `sh`.
**Spec:** `docs/superpowers/specs/2026-07-27-lectio-national-bibles-design.md`
## Global Constraints
- **Corpus text format:** exactly 6 tab-separated columns, UTF-8, one verse per row: `Book · Abbrev · BookNum · Chapter · Verse · Text`. Loader uses columns 1/4/5/6; columns 2/3 are informational.
- **`Book` column:** canonical English name; must be one of the 73 keys in `internal/bible/books.ini`.
- **Metadata sidecar `<code>.ini`:** keys `lang` (required), `name` (required), `psalm_system` (required, one of `vulgate`/`hebrew`/`drb`), `sigla` (optional). Parsed with `internal/ini` (full-line comments only).
- **User corpus dir:** `~/.config/lectio/corpora/`. A user `<code>` overrides the embedded one; new codes add.
- **Selection order:** `reading_version` (explicit code) → corpus whose `lang` == `reading_lang` → corpus whose `lang` == `ui_language` → `""` (caller uses `vul`).
- **`traditional_lang` is not touched** and is never read by the corpus resolver.
- **`psalm_system` is metadata only** in this feature — captured + validated, never applied (no offline psalm parts exist yet).
- **Purity:** `internal/bible` imports only stdlib + `internal/ini`. No network, no new third-party deps.
- **Non-breaking:** `--ref`, version comparison, TUI/web daily view, and the scraper path keep working unchanged.
- **Every task ends green:** `gofmt -l` clean and `go test ./...` passing before commit.
---
### Task 1: Built-in sidecars + metadata parser
**Files:**
- Create: `internal/bible/corpora/vul.ini`, `drb.ini`, `grb.ini`, `wuj.ini`
- Modify: `internal/bible/bible.go` (embed directive)
- Create: `internal/bible/corpusmeta.go`
- Test: `internal/bible/corpusmeta_test.go`
**Interfaces:**
- Produces: `type CorpusMeta struct { Code, Lang, Name, PsalmSystem, Sigla string }`; `func parseCorpusMeta(code string, data []byte) CorpusMeta`; `func embeddedCorpusMeta(code string) (CorpusMeta, bool)`.
- [ ] **Step 1: Write the sidecars.** Each is four lines. `internal/bible/corpora/vul.ini`:
```ini
; lectio corpus metadata
lang = la
name = Vulgate (Latin)
psalm_system = vulgate
```
`drb.ini`: `lang = en`, `name = Douay-Rheims (English)`, `psalm_system = drb`.
`grb.ini`: `lang = el`, `name = Greek`, `psalm_system = vulgate`.
`wuj.ini`: `lang = pl`, `name = Wujek (Polish)`, `psalm_system = vulgate`.
- [ ] **Step 2: Widen the embed** in `internal/bible/bible.go` — replace the explicit `//go:embed corpora/wuj.tsv corpora/vul.tsv corpora/grb.tsv corpora/drb.tsv` with:
```go
//go:embed corpora
var corporaFS embed.FS
```
(embedding the whole dir picks up both `*.tsv` and `*.ini`; existing `corporaFS.ReadFile("corpora/"+version+".tsv")` calls are unchanged.)
- [ ] **Step 3: Write the failing test** `internal/bible/corpusmeta_test.go`:
```go
package bible
import "testing"
func TestEmbeddedCorpusMeta(t *testing.T) {
m, ok := embeddedCorpusMeta("drb")
if !ok {
t.Fatal("drb sidecar not found")
}
if m.Lang != "en" || m.PsalmSystem != "drb" || m.Name == "" {
t.Fatalf("bad drb meta: %+v", m)
}
}
```
- [ ] **Step 4: Run it, verify it fails** — `go test ./internal/bible/ -run TestEmbeddedCorpusMeta` → FAIL (undefined `embeddedCorpusMeta`).
- [ ] **Step 5: Implement** `internal/bible/corpusmeta.go`:
```go
package bible
import "github.com/lukaszkasprzak/lectio/internal/ini"
// CorpusMeta is the sidecar metadata for a bible corpus (<code>.ini).
type CorpusMeta struct {
Code, Lang, Name, PsalmSystem, Sigla string
}
// parseCorpusMeta reads a section-less sidecar. ini.Parse returns
// []ini.Section{Name, Pairs}; keys before any [section] land in the section
// whose Name == "" (verified against internal/ini).
func parseCorpusMeta(code string, data []byte) CorpusMeta {
m := CorpusMeta{Code: code}
secs, err := ini.Parse(data)
if err != nil {
return m
}
for _, s := range secs {
if s.Name != "" {
continue
}
for _, p := range s.Pairs {
switch p.Key {
case "lang":
m.Lang = p.Val
case "name":
m.Name = p.Val
case "psalm_system":
m.PsalmSystem = p.Val
case "sigla":
m.Sigla = p.Val
}
}
}
return m
}
func embeddedCorpusMeta(code string) (CorpusMeta, bool) {
data, err := corporaFS.ReadFile("corpora/" + code + ".ini")
if err != nil {
return CorpusMeta{}, false
}
return parseCorpusMeta(code, data), true
}
```
- [ ] **Step 6: Run tests** — `go test ./internal/bible/` → PASS. Then `gofmt -l internal/bible/`.
- [ ] **Step 7: Commit** — `git add -A && git commit -m "feat(bible): corpus metadata sidecars + parser"`
---
### Task 2: Corpus registry, user-dir discovery, override
**Files:**
- Modify: `internal/bible/bible.go` (registry + `load` lookup)
- Create: `internal/bible/registry.go`
- Modify: `internal/config/config.go` (add `CorporaDir()`)
- Test: `internal/bible/registry_test.go`
**Interfaces:**
- Consumes: `CorpusMeta`, `embeddedCorpusMeta` (Task 1).
- Produces: `func SetUserCorporaDir(dir string)`; `func Corpora() []CorpusMeta`; `func Meta(code string) (CorpusMeta, bool)`; `func CorporaForLang(lang string) []CorpusMeta`. `load(code)` reads the user file when present, else embed.
- `config.CorporaDir() (string, error)` → `~/.config/lectio/corpora/`.
- [ ] **Step 1: Add the config path helper** to `internal/config/config.go`, mirroring the existing `CalendarsDir()`:
```go
// CorporaDir is the user directory for drop-in bible corpora (<code>.tsv +
// <code>.ini), alongside calendars/ under the lectio config dir. Mirrors
// CalendarsDir exactly (verified: it uses configPath() then filepath.Dir).
func CorporaDir() (string, error) {
p, _, err := configPath()
if err != nil {
return "", err
}
return filepath.Join(filepath.Dir(p), "corpora"), nil
}
```
- [ ] **Step 2: Write the failing test** `internal/bible/registry_test.go`:
```go
package bible
import (
"os"
"path/filepath"
"testing"
)
func TestUserCorpusOverride(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "drb.tsv"), []byte("Genesis\tGn\t1\t1\t1\tUSER TEXT\n"), 0o644)
os.WriteFile(filepath.Join(dir, "drb.ini"), []byte("lang = en\nname = User DRB\npsalm_system = drb\n"), 0o644)
SetUserCorporaDir(dir)
t.Cleanup(func() { SetUserCorporaDir("") })
if got := Verses("drb", "Genesis", 1); len(got) == 0 || got[0].Text != "USER TEXT" {
t.Fatalf("user drb.tsv did not override embed: %+v", got)
}
m, _ := Meta("drb")
if m.Name != "User DRB" {
t.Fatalf("user sidecar not used: %+v", m)
}
}
func TestUserCorpusNewCode(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "fr-x.tsv"), []byte("Genesis\tGn\t1\t1\t1\tAu commencement\n"), 0o644)
os.WriteFile(filepath.Join(dir, "fr-x.ini"), []byte("lang = fr\nname = Test FR\npsalm_system = vulgate\n"), 0o644)
SetUserCorporaDir(dir)
t.Cleanup(func() { SetUserCorporaDir("") })
if l := CorporaForLang("fr"); len(l) != 1 || l[0].Code != "fr-x" {
t.Fatalf("new fr corpus not registered: %+v", l)
}
}
```
- [ ] **Step 3: Run it, verify it fails** — `go test ./internal/bible/ -run TestUserCorpus` → FAIL (undefined symbols).
- [ ] **Step 4: Implement** `internal/bible/registry.go`:
```go
package bible
import (
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
var (
regMu sync.Mutex
userDir string
regBuilt bool
regMeta map[string]CorpusMeta // code -> meta (user overrides embed)
regUserTSV map[string]string // code -> user .tsv path (override source)
)
// SetUserCorporaDir sets the drop-in dir and invalidates the registry cache.
// Empty string disables user corpora (used by tests).
func SetUserCorporaDir(dir string) {
regMu.Lock()
defer regMu.Unlock()
userDir = dir
regBuilt = false
corpora = map[string]*corpus{} // drop cached loads so overrides take effect
}
func buildRegistry() {
if regBuilt {
return
}
regMeta = map[string]CorpusMeta{}
regUserTSV = map[string]string{}
// embedded first
ents, _ := corporaFS.ReadDir("corpora")
for _, e := range ents {
if code, ok := strings.CutSuffix(e.Name(), ".ini"); ok {
if m, ok := embeddedCorpusMeta(code); ok {
regMeta[code] = m
}
}
}
// user dir overrides
if userDir != "" {
ents, _ := os.ReadDir(userDir)
for _, e := range ents {
name := e.Name()
if code, ok := strings.CutSuffix(name, ".tsv"); ok {
regUserTSV[code] = filepath.Join(userDir, name)
if _, have := regMeta[code]; !have {
regMeta[code] = CorpusMeta{Code: code, PsalmSystem: "vulgate"}
}
}
}
for _, e := range ents {
if code, ok := strings.CutSuffix(e.Name(), ".ini"); ok {
data, err := os.ReadFile(filepath.Join(userDir, e.Name()))
if err == nil {
regMeta[code] = parseCorpusMeta(code, data)
}
}
}
}
regBuilt = true
}
// Corpora returns all known corpora, sorted by code.
func Corpora() []CorpusMeta {
regMu.Lock()
defer regMu.Unlock()
buildRegistry()
out := make([]CorpusMeta, 0, len(regMeta))
for _, m := range regMeta {
out = append(out, m)
}
sort.Slice(out, func(i, j int) bool { return out[i].Code < out[j].Code })
return out
}
// Meta returns a corpus's metadata.
func Meta(code string) (CorpusMeta, bool) {
regMu.Lock()
defer regMu.Unlock()
buildRegistry()
m, ok := regMeta[code]
return m, ok
}
// CorporaForLang returns corpora whose lang == lang, sorted by code.
func CorporaForLang(lang string) []CorpusMeta {
var out []CorpusMeta
for _, m := range Corpora() {
if m.Lang == lang {
out = append(out, m)
}
}
return out
}
// userTSVPath returns the override .tsv path for code, or "".
func userTSVPath(code string) string {
regMu.Lock()
defer regMu.Unlock()
buildRegistry()
return regUserTSV[code]
}
```
- [ ] **Step 5: Route `load()` through the override** in `internal/bible/bible.go` — change the read source:
```go
func load(version string) *corpus {
corporaMu.Lock()
defer corporaMu.Unlock()
if c, ok := corpora[version]; ok {
return c
}
var data []byte
if p := userTSVPath(version); p != "" {
data, _ = os.ReadFile(p)
}
if data == nil {
data, _ = corporaFS.ReadFile("corpora/" + version + ".tsv")
}
if data == nil {
corpora[version] = &corpus{books: map[string]map[int][]Verse{}}
return corpora[version]
}
// ... existing parse loop unchanged, using `data` ...
}
```
(Add `"os"` to the imports if not present. `userTSVPath` takes `regMu`; `load` holds `corporaMu` — different locks, no deadlock. `SetUserCorporaDir` resets `corpora` under `regMu`; guard the reset by also taking `corporaMu` if the race detector flags it — see Step 7.)
- [ ] **Step 6: Run tests** — `go test ./internal/bible/ -run TestUserCorpus` → PASS.
- [ ] **Step 7: Race check** — `go test -race ./internal/bible/`. If `SetUserCorporaDir`'s `corpora = ...` reset races with `load`, take `corporaMu` around the reset inside `SetUserCorporaDir`. Re-run until clean.
- [ ] **Step 8: Full suite + gofmt + commit** — `go test ./...`; `gofmt -l internal/`; `git commit -m "feat(bible): user corpus discovery + override registry"`.
---
### Task 3: Config fields + `ReadingCorpus` resolver
**Files:**
- Modify: `internal/config/config.go` (struct fields, setField, renderConfigINI, config header)
- Test: `internal/config/config_test.go`
**Interfaces:**
- Consumes: `bible.Corpora`, `bible.Meta` (Task 2).
- Produces: `Config.ReadingLang`, `Config.ReadingVersion` (strings); `func (c Config) ReadingCorpus() string`.
- [ ] **Step 1: Write the failing test** in `internal/config/config_test.go`:
```go
func TestReadingCorpusResolution(t *testing.T) {
// drb is an embedded corpus with lang=en.
c := Config{ReadingVersion: "drb"}
if got := c.ReadingCorpus(); got != "drb" {
t.Fatalf("explicit reading_version: got %q", got)
}
c = Config{ReadingLang: "en"}
if got := c.ReadingCorpus(); got != "drb" {
t.Fatalf("reading_lang match: got %q", got)
}
c = Config{UILanguage: "en"}
if got := c.ReadingCorpus(); got != "drb" {
t.Fatalf("ui_language fallback: got %q", got)
}
c = Config{UILanguage: "pl"} // no pl corpus embedded
if got := c.ReadingCorpus(); got != "" {
t.Fatalf("no match should be empty: got %q", got)
}
}
```
- [ ] **Step 2: Run it, verify it fails** — `go test ./internal/config/ -run TestReadingCorpus` → FAIL.
- [ ] **Step 3: Add the struct fields** to `Config` in `internal/config/config.go` (near `UILanguage`):
```go
ReadingLang string `toml:"reading_lang"`
ReadingVersion string `toml:"reading_version"`
```
- [ ] **Step 4: Add setField cases** in the `switch key` block:
```go
case "reading_lang":
cfg.ReadingLang = val
case "reading_version":
cfg.ReadingVersion = val
```
- [ ] **Step 5: Implement the resolver** (new method, `internal/config/config.go`):
```go
// ReadingCorpus resolves which bible corpus renders offline reading text:
// an explicit reading_version, else the first corpus matching reading_lang,
// else the first matching ui_language, else "" (caller uses the Latin vul).
func (c Config) ReadingCorpus() string {
if c.ReadingVersion != "" {
if _, ok := bible.Meta(c.ReadingVersion); ok {
return c.ReadingVersion
}
}
for _, lang := range []string{c.ReadingLang, NormalizeUILanguage(c.UILanguage)} {
if lang == "" {
continue
}
if m := bible.CorporaForLang(lang); len(m) > 0 {
return m[0].Code
}
}
return ""
}
```
(Add the `internal/bible` import to config. Confirm no import cycle: `bible` must not import `config`. Task 2's `CorporaDir()` lives in `config`, and `bible` gets the dir via `SetUserCorporaDir` called from `main`/CLI — so `bible` does NOT import `config`. Verify before committing.)
- [ ] **Step 6: Render the fields** in `renderConfigINI` (after `ui_language`):
```go
fmt.Fprintf(&b, "reading_lang = %s\n", cfg.ReadingLang)
fmt.Fprintf(&b, "reading_version = %s\n", cfg.ReadingVersion)
```
And add two lines to the config-header doc block explaining them (match the existing comment style: `reading_lang` = language for offline reading text, blank follows ui_language; `reading_version` = force a specific corpus code).
- [ ] **Step 7: Run tests + gofmt** — `go test ./internal/config/ ./internal/bible/`; `gofmt -l internal/`. Confirm no import cycle (`go build ./...`).
- [ ] **Step 8: Commit** — `git commit -m "feat(config): reading_lang/reading_version + ReadingCorpus resolver"`
---
### Task 4: `lectio --corpus-check` validator
**Files:**
- Create: `internal/bible/validate.go`
- Modify: `internal/cli/cli.go` (flag wiring) and `internal/cli/corpus.go` (new command handler)
- Test: `internal/bible/validate_test.go`; fixtures under `internal/bible/testdata/corpora/`
**Interfaces:**
- Consumes: `Corpora`, `Meta`, `Verses`, `load` (Task 2); the canonical book set.
- Produces: `type CorpusReport struct { Errors, Warnings []string }`; `func CheckCorpus(code string) CorpusReport`; `func (r CorpusReport) OK() bool`.
- [ ] **Step 1: Expose the canonical book set.** In `internal/bible/booktable.go` (or wherever `books.ini` is parsed), add `func CanonicalBooks() map[string]bool` returning the 73 English keys. Reuse the existing parse; do not re-embed.
- [ ] **Step 2: Write fixtures** under `internal/bible/testdata/corpora/`:
- `good.tsv` (a few valid rows across 2 books) + `good.ini` (valid metadata).
- `badbook.tsv` (one row with `Genessis` typo).
- `gap.tsv` (Genesis 1 verses 1,2,4 — missing 3).
- `nosidecar.tsv` (valid rows, no `.ini`).
- `badsystem.ini` + `badsystem.tsv` (`psalm_system = klingon`).
- [ ] **Step 3: Write the failing test** `internal/bible/validate_test.go`:
```go
func TestCheckCorpus(t *testing.T) {
SetUserCorporaDir("testdata/corpora")
t.Cleanup(func() { SetUserCorporaDir("") })
if r := CheckCorpus("good"); !r.OK() {
t.Fatalf("good corpus flagged: %v", r.Errors)
}
if r := CheckCorpus("badbook"); r.OK() {
t.Fatal("unknown book not caught")
}
if r := CheckCorpus("gap"); len(r.Warnings) == 0 {
t.Fatal("verse gap not warned")
}
if r := CheckCorpus("nosidecar"); r.OK() {
t.Fatal("missing sidecar not caught")
}
if r := CheckCorpus("badsystem"); r.OK() {
t.Fatal("bad psalm_system not caught")
}
}
```
- [ ] **Step 4: Run it, verify it fails** — `go test ./internal/bible/ -run TestCheckCorpus` → FAIL.
- [ ] **Step 5: Implement `CheckCorpus`** in `internal/bible/validate.go`:
```go
package bible
import (
"fmt"
"sort"
)
type CorpusReport struct {
Code string
Errors []string
Warnings []string
}
func (r CorpusReport) OK() bool { return len(r.Errors) == 0 }
var validPsalmSystems = map[string]bool{"vulgate": true, "hebrew": true, "drb": true}
// CheckCorpus validates a corpus's text + sidecar and reports coverage vs vul.
func CheckCorpus(code string) CorpusReport {
r := CorpusReport{Code: code}
// sidecar
m, ok := Meta(code)
if !ok || m.Lang == "" || m.Name == "" {
r.Errors = append(r.Errors, "missing or incomplete sidecar (need lang, name, psalm_system)")
}
if m.PsalmSystem != "" && !validPsalmSystems[m.PsalmSystem] {
r.Errors = append(r.Errors, fmt.Sprintf("invalid psalm_system %q", m.PsalmSystem))
}
// text present?
c := load(code)
if len(c.books) == 0 {
r.Errors = append(r.Errors, "no verses parsed (empty or malformed .tsv)")
return r
}
// canonical book names
canon := CanonicalBooks()
for book := range c.books {
if !canon[book] {
r.Errors = append(r.Errors, "unknown book name: "+book)
}
}
// verse integrity + coverage vs vul
ref := load("vul")
for book, chaps := range c.books {
for ch, verses := range chaps {
seen := map[int]bool{}
for _, v := range verses {
if seen[v.Verse] {
r.Errors = append(r.Errors, fmt.Sprintf("%s %d: duplicate verse %d", book, ch, v.Verse))
}
seen[v.Verse] = true
}
}
if refChaps, ok := ref.books[book]; ok {
for ch := range refChaps {
if _, have := chaps[ch]; !have {
r.Warnings = append(r.Warnings, fmt.Sprintf("%s: missing chapter %d (present in vul)", book, ch))
}
}
}
}
sort.Strings(r.Errors)
sort.Strings(r.Warnings)
return r
}
```
(Gap detection: within each chapter, warn when the max verse number exceeds the count by more than a small threshold, reusing the run-shape logic from the session audit — keep it a warning, never an error.)
- [ ] **Step 6: Run tests** — `go test ./internal/bible/ -run TestCheckCorpus` → PASS.
- [ ] **Step 7: Wire the CLI flag.** In `internal/cli/cli.go`, add a `--corpus-check <code>` flag (and `--json`); on set, call a handler in new `internal/cli/corpus.go` that resolves the user dir via `config.CorporaDir()` + `bible.SetUserCorporaDir`, runs `bible.CheckCorpus`, prints errors/warnings (or JSON), and returns exit 1 if `!report.OK()`. Accept a path argument too: if the arg contains a `/` or ends `.tsv`, point `SetUserCorporaDir` at its dir and use its basename as the code.
- [ ] **Step 8: CLI smoke test** — `go run ./cmd/lectio --corpus-check drb` prints a clean report, exit 0. Add a `internal/cli` test asserting exit code 1 for the `badbook` fixture.
- [ ] **Step 9: Full suite + gofmt + commit** — `go test ./...`; `gofmt -l internal/`; `git commit -m "feat(cli): lectio --corpus-check validator"`.
---
### Task 5: Wire resolver into rendering + surface user corpora
**Files:**
- Modify: `internal/cli/liturgy.go` (`vernacularVersion`)
- Modify: `internal/cli/cli.go` or main wiring (call `bible.SetUserCorporaDir` at startup)
- Modify: `internal/cli` `--list` and comparison label lookup
- Test: `internal/cli/liturgy_test.go`
**Interfaces:**
- Consumes: `Config.ReadingCorpus` (Task 3), `bible.Corpora`, `bible.Meta` (Task 2).
- [ ] **Step 1: Set the user corpora dir at startup.** Wherever the CLI builds `cfg` (the same place it resolves `config.CalendarsDir()` for the calendar stack), call:
```go
if dir, err := config.CorporaDir(); err == nil {
bible.SetUserCorporaDir(dir)
}
```
so both `--ref`, comparison, and `--liturgy` see user corpora.
- [ ] **Step 2: Write the failing test** in `internal/cli/liturgy_test.go`:
```go
func TestVernacularVersionResolver(t *testing.T) {
if got := vernacularVersion(config.Config{ReadingVersion: "drb"}); got != "drb" {
t.Fatalf("explicit: %q", got)
}
if got := vernacularVersion(config.Config{UILanguage: "pl"}); got != "vul" {
t.Fatalf("pl should fall back to Latin: %q", got)
}
if got := vernacularVersion(config.Config{UILanguage: "en"}); got != "drb" {
t.Fatalf("en should be drb: %q", got)
}
}
```
- [ ] **Step 3: Run it, verify it fails** (current `vernacularVersion` ignores `ReadingVersion`).
- [ ] **Step 4: Replace `vernacularVersion`** in `internal/cli/liturgy.go`:
```go
// vernacularVersion is the corpus that renders reading text: the config's
// resolved reading corpus, else the complete Latin Vulgate.
func vernacularVersion(cfg config.Config) string {
if code := cfg.ReadingCorpus(); code != "" {
return code
}
return latinFallback // "vul"
}
```
- [ ] **Step 5: Run tests** — `go test ./internal/cli/ -run TestVernacularVersion` → PASS. Manually confirm behaviour with a temp config: `LECTIO_CONFIG=… lectio 2025-03-10 -L` shows Latin for pl, Douay for en (unchanged from today).
- [ ] **Step 6: Surface corpora in `--list`/comparison.** Where version labels are resolved (today via `i18n.Get(lang).Version[code]`), fall back to `bible.Meta(code).Name` when the i18n map has no entry, and include user corpora when enumerating available versions. Add a test asserting a user fixture corpus appears in the `--list` output.
- [ ] **Step 7: Full suite + gofmt + commit** — `go test ./...`; `gofmt -l internal/`; `git commit -m "feat(cli): config-driven reading corpus + surface user corpora"`.
---
### Task 6: Scripts + Makefile
**Files:**
- Create: `scripts/corpus-validate.sh`
- Modify: `Makefile`
- Test: manual + `check-corpora` target
**Interfaces:**
- Consumes: `lectio --corpus-check` (Task 4).
- [ ] **Step 1: Write** `scripts/corpus-validate.sh`:
```sh
#!/bin/sh
# Validate a bible corpus by code or by .tsv path (see lectio --corpus-check).
# Usage: scripts/corpus-validate.sh <code|path-to-tsv>
set -e
[ -n "$1" ] || { echo "usage: $0 <code|path.tsv>" >&2; exit 2; }
exec go run ./cmd/lectio --corpus-check "$1"
```
`chmod +x scripts/corpus-validate.sh`.
- [ ] **Step 2: Add Makefile targets:**
```make
# Validate every embedded corpus (CI gate).
check-corpora:
@for f in internal/bible/corpora/*.tsv; do \
code=$$(basename $$f .tsv); \
echo "checking $$code"; \
go run ./cmd/lectio --corpus-check $$code || exit 1; \
done
# Validate then embed a corpus pair: make add-corpus CORPUS=fr-crampon SRC=/path/to/dir
add-corpus:
@test -n "$(CORPUS)" || { echo "set CORPUS=<code>" >&2; exit 2; }
@test -n "$(SRC)" || { echo "set SRC=<dir with $(CORPUS).tsv/.ini>" >&2; exit 2; }
go run ./cmd/lectio --corpus-check "$(SRC)/$(CORPUS).tsv"
cp "$(SRC)/$(CORPUS).tsv" "$(SRC)/$(CORPUS).ini" internal/bible/corpora/
$(MAKE) build
```
- [ ] **Step 3: Wire `check-corpora` into the test/check target** — add it as a prerequisite of the existing `test` (or `check`) target so CI runs it.
- [ ] **Step 4: Verify** — `make check-corpora` passes for all four built-ins. Create a throwaway bad pair in `/tmp` and confirm `make add-corpus CORPUS=bad SRC=/tmp` aborts before copying (validation non-zero). Clean up.
- [ ] **Step 5: Commit** — `git commit -m "build: corpus-validate script + add-corpus/check-corpora make targets"`.
---
## Wrap-up
- [ ] Run the full suite once more: `go test ./... && gofmt -l internal/ scripts/ cmd/`.
- [ ] Bump `config.Version` (0.33.0 → 0.34.0).
- [ ] Update memory `lectio-selfcontained-calendar-epic.md`: national-bibles corpora mechanism shipped; how a corpus is added; that a corrected Wujek is now a drop-in.
- [ ] Use **superpowers:finishing-a-development-branch** to merge/install.
- [ ] Manual acceptance: drop a tiny `fr-x.tsv` + `fr-x.ini` into `~/.config/lectio/corpora/`, set `reading_version = fr-x` (temp config), confirm `lectio … -L` renders the French text and `--corpus-check fr-x` reports coverage.
## Self-Review Notes
- Spec coverage: format (T1), discovery/override (T2), config/selection (T3), validation (T4), rendering/surfacing (T5), scripts/Makefile (T6). `psalm_system` = metadata+validation only (T1, T4), application deferred per spec §5. All spec sections mapped.
- Import-cycle watch (T3): `bible` must not import `config`; the user dir is injected via `SetUserCorporaDir` from the CLI, not read inside `bible`.
- Concurrency watch (T2): two locks (`regMu`, `corporaMu`); `SetUserCorporaDir` resets the load cache — race-test in T2 Step 7.
- Confirm-before-coding: `ini.Parse`'s exact return shape (T1 Step 5) and `CalendarsDir`'s helper (T2 Step 1) must be read from the current code, not assumed.
|