// Package cli is lectio's flag-driven command dispatcher: it wires config, // the readings router, liturgy.Harvest and render together into the // `lectio` binary's Run entry point. package cli import ( "flag" "fmt" "io" "os" "regexp" "strconv" "strings" "time" "github.com/lukaszkasprzak/lectio/internal/config" "github.com/lukaszkasprzak/lectio/internal/i18n" "github.com/lukaszkasprzak/lectio/internal/liturgy" "github.com/lukaszkasprzak/lectio/internal/readings" "github.com/lukaszkasprzak/lectio/internal/render" "github.com/lukaszkasprzak/lectio/internal/tradlit" ) const helpText = `lectio — daily Catholic liturgy readings (Polish + 4 versions) Usage: lectio [DATE] [flags] DATE = YYYY-MM-DD (default: today), any position Flags: -a, --all all readings, not just the gospel -b, --bible VER one version's text: pl,wuj,vul,grb,drb -c, --compare LIST versions side by side (comma-separated) -r, --raw text only, no banner/headings (for piping) -w, --width N wrap width; 0 = detect terminal -R, --refresh ignore cache, re-download -o, --offline cache/sigla only, no network -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 -P, --pager page reading output (like git); default from config --no-pager never page, even if config sets one -v, --version print the version and exit -h, --help this help Versions: pl (Polski/niedziela.pl) wuj (Wujek) vul (Wulgata) grb (Grecki) drb (Douay-Rheims) Examples: lectio today's gospel lectio 2026-07-22 -a all readings for a date lectio -c pl,wuj 2026-07-22 compare two versions for a date lectio -b vul -a Vulgate text, all readings lectio -l trad -a traditional lectionary, all readings lectio -u harvest sigla to the horizon Flags override config. Exit codes: 0 ok, 1 runtime error (fetch/parse), 2 usage error (bad flag, bad date, bad version, bad --lectionary/--lang). ` const ( defaultWidth = 80 compareDefaultWidth = 120 ) var dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) func today() string { return time.Now().Format("2006-01-02") } // Run is the CLI entry point; returns a process exit code. func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { if wantsHelp(args) { fmt.Fprint(stdout, helpText) return 0 } if wantsVersion(args) { fmt.Fprintln(stdout, "lectio "+config.Version) return 0 } if len(args) > 0 && args[0] == "help" { fmt.Fprint(stdout, helpText) return 0 } date, rest, err := extractDate(args) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 2 } var all, raw, refresh, offline, update, clean, pagerFlag, noPager bool var bibleVer, compareList, lectionary, lang string var width int fs := flag.NewFlagSet("lectio", flag.ContinueOnError) fs.SetOutput(stderr) fs.Usage = func() { fmt.Fprint(stderr, helpText) } fs.BoolVar(&all, "a", false, "all readings, not just the gospel") fs.BoolVar(&all, "all", false, "all readings, not just the gospel") fs.StringVar(&bibleVer, "b", "", "one version's text: pl,wuj,vul,grb,drb") fs.StringVar(&bibleVer, "bible", "", "one version's text: pl,wuj,vul,grb,drb") fs.StringVar(&compareList, "c", "", "versions side by side (comma list)") fs.StringVar(&compareList, "compare", "", "versions side by side (comma list)") fs.BoolVar(&raw, "r", false, "text only, no banner/headings") fs.BoolVar(&raw, "raw", false, "text only, no banner/headings") fs.IntVar(&width, "w", 0, "wrap width (0 = detect terminal)") fs.IntVar(&width, "width", 0, "wrap width (0 = detect terminal)") fs.BoolVar(&refresh, "R", false, "ignore cache, re-download") fs.BoolVar(&refresh, "refresh", false, "ignore cache, re-download") fs.BoolVar(&offline, "o", false, "cache/sigla only, no network") fs.BoolVar(&offline, "offline", false, "cache/sigla only, no network") fs.StringVar(&lectionary, "l", "", "new|trad") fs.StringVar(&lectionary, "lectionary", "", "new|trad") fs.StringVar(&lang, "g", "", "pl|en") 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") fs.BoolVar(&pagerFlag, "P", false, "page reading output (like git)") fs.BoolVar(&pagerFlag, "pager", false, "page reading output (like git)") fs.BoolVar(&noPager, "no-pager", false, "never page, even if config sets one") if err := fs.Parse(rest); err != nil { return 2 } if fs.NArg() > 0 { fmt.Fprintf(stderr, "lectio: unexpected argument %q; see 'lectio -h'\n", fs.Arg(0)) return 2 } lectionary, err = normalizeLectionary(lectionary) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 2 } if lang != "" && lang != "pl" && lang != "en" { fmt.Fprintf(stderr, "lectio: invalid --lang %q (want pl|en)\n", lang) return 2 } if clean { return runClean(stdout, stderr) } if update { return runHarvest(date, stdout, stderr) } cfg, err := config.Load() if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } if offline { cfg.Offline = true } if lectionary != "" { cfg.Lectionary = lectionary } if lang != "" { cfg.TraditionalLang = lang } effAll := all || cfg.All effWidth := width if effWidth == 0 { effWidth = cfg.Width } if effWidth == 0 { // Detect the real terminal width now, from stdout, before it may be // replaced by the pager's pipe below -- otherwise paging would wrap at // the default width instead of the terminal's. if tw := termWidth(stdout); tw > 0 { effWidth = tw } } // Validate versions before any pager starts -- don't page an error. When // compareList != "", bibleVer is unused (compare wins, as before) so it's // deliberately left unvalidated in that case. if bibleVer != "" && compareList == "" && !config.ValidVersion(bibleVer) { fmt.Fprintf(stderr, "lectio: unknown version %q (want one of pl, wuj, vul, grb, drb)\n", bibleVer) return 2 } if compareList != "" { for _, v := range strings.Split(compareList, ",") { if v = strings.TrimSpace(v); v != "" && !config.ValidVersion(v) { fmt.Fprintf(stderr, "lectio: unknown version %q (want one of pl, wuj, vul, grb, drb)\n", v) return 2 } } } out := stdout finish := func() {} if pagerRequested(pagerFlag, noPager, cfg) && isTerminalWriter(stdout) { if o, f, ok := startPager(pagerCommand(cfg), stdout, stderr); ok { out = o finish = f } } var code int switch { case compareList != "": code = renderCompare(cfg, compareList, date, effAll, raw, effWidth, refresh, out, stderr) case bibleVer != "": code = fetchAndPrint(cfg, bibleVer, date, effAll, raw, effWidth, refresh, out, stderr) default: code = fetchAndPrint(cfg, cfg.DefaultVersion, date, effAll, raw, effWidth, refresh, out, stderr) } finish() return code } // wantsHelp reports whether -h/--help appears anywhere in args. func wantsHelp(args []string) bool { for _, a := range args { if a == "-h" || a == "--help" { return true } } return false } // wantsVersion reports whether -v/--version appears anywhere in args (like // wantsHelp), so `lectio DATE -v` prints the version rather than erroring on // an "undefined flag". func wantsVersion(args []string) bool { for _, a := range args { if a == "-v" || a == "--version" { return true } } return false } // extractDate pulls the single positional DATE token (YYYY-MM-DD, matching // dateRe) out of args, wherever it appears, and returns it along with the // remaining tokens for flag.FlagSet to parse. No flag's own value can match // dateRe's shape (width is an int, versions/lists/lang/lectionary are short // codes), so scanning raw tokens this way is unambiguous. Defaults to // today() when no date token is present; errors if more than one is found. func extractDate(args []string) (date string, rest []string, err error) { found := false for _, a := range args { if dateRe.MatchString(a) { if found { return "", nil, fmt.Errorf("multiple dates given (%q and %q)", date, a) } date = a found = true continue } rest = append(rest, a) } if !found { date = today() } return date, rest, nil } // normalizeLectionary maps -l/--lectionary's accepted spellings ("new", // "trad", "traditional") onto the canonical config.Config.Lectionary values // ("new", "traditional") via config.NormalizeLectionary; "" (flag not given) // passes through unchanged so the caller knows to leave config's own // setting alone. func normalizeLectionary(lectionary string) (string, error) { if lectionary == "" { return "", nil } v, ok := config.NormalizeLectionary(lectionary) if !ok { return "", fmt.Errorf("invalid --lectionary %q (want new|trad)", lectionary) } return v, nil } // runHarvest handles -u/--update: harvest sigla maximally (to the // unpublished horizon) from date for the modern lectionary, printing the // outcome or -- on a genuine interruption (see liturgy.Harvest) -- the // error. On a successful harvest it also best-effort pre-caches the // traditional lectionary's propers (internal/tradlit) for every date in // that same [date, furthest] window, so 'lectio update' prepares both // lectionaries for offline use in one run. The 1962 calendar has no // "unpublished horizon" (every date has propers), so this is a plain // date-range fetch; a per-date failure is not fatal here -- it never // prevented the traditional lectionary from working live before, and the // modern-harvest outcome is still reported either way. func runHarvest(date string, stdout, stderr io.Writer) int { added, furthest, err := liturgy.Harvest(date, 0) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } lang := config.Default().TraditionalLang cachedTrad := 0 if furthest != "" { if cfg, cfgErr := config.Load(); cfgErr == nil { lang = cfg.TraditionalLang cachedTrad = cacheTraditionalRange(date, furthest, lang) } } fmt.Fprintf(stdout, "harvested %d day(s), furthest %s; cached traditional propers (%s) for %d day(s)\n", added, furthest, lang, cachedTrad) return 0 } // cacheTraditionalRange best-effort pre-caches tradlit's traditional propers // for lang for every date in [from, to] inclusive (walking forward a day at // a time), returning how many dates succeeded. A per-date failure (e.g. a // transient network hiccup) is ignored -- the traditional 1962 calendar has // propers for every date, so there is no "unpublished horizon" to stop at // the way liturgy.Harvest has for the modern lectionary. func cacheTraditionalRange(from, to, lang string) int { start, err := time.Parse("2006-01-02", from) if err != nil { return 0 } end, err := time.Parse("2006-01-02", to) if err != nil { return 0 } cached := 0 for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { if _, err := tradlit.Load(d.Format("2006-01-02"), lang, false); err == nil { cached++ } } return cached } // 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. func fetchAndPrint(cfg config.Config, version, date string, all, raw bool, width int, refresh bool, stdout, stderr io.Writer) int { secs, err := readings.Load(cfg, readings.Options{ Date: date, Refresh: refresh, Offline: cfg.Offline, All: all, }) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } if len(secs) == 0 { fmt.Fprintln(stderr, "lectio: no readings found for", date) return 1 } if vs := render.EffectiveVersions([]string{version}, cfg.Lectionary, cfg.Offline); len(vs) > 0 { version = vs[0] } w := resolveWidth(width, false, stdout) if !raw { banner := bannerFor(cfg.UILanguage, all, date) fmt.Fprintln(stdout, banner) fmt.Fprintln(stdout, strings.Repeat("=", minInt(len(banner), w))) fmt.Fprintln(stdout) } pieces := make([]string, 0, len(secs)) for _, sec := range secs { pieces = append(pieces, renderSection(sec, version, cfg.Lectionary, cfg.UILanguage, w, raw)) } fmt.Fprintln(stdout, strings.Join(pieces, "\n\n")) return 0 } // bannerFor builds the " DATE" banner: the // "readings" word when every part is shown, "gospel" for the gospel-only // default, and the connective between word and date, all localised via // i18n.Get(lang) -- lang="pl" reproduces ewangelia.py's original Polish // wording exactly ("Ewangelia na DATE" / "Czytania na DATE"); lang="en" // gives "Gospel for DATE" / "Readings for DATE". func bannerFor(lang string, all bool, date string) string { ui := i18n.Get(lang) word := ui.BannerGospel if all { word = ui.BannerReadings } return word + " " + ui.BannerConnective + " " + date } // renderSection formats one section as its heading (unless raw) followed by // render.GatherVersion's blocks, each wrapped to width. The heading's part // label is localised via render.LocalizeHeading (lang); the citation/verse // text is never touched. func renderSection(sec liturgy.Section, version, lectionary, lang string, width int, raw bool) string { var lines []string if !raw { lines = append(lines, render.LocalizeHeading(sec.Heading, sec.PartID, lang)) lines = append(lines, "") } _, blocks := render.GatherVersion(version, sec, lectionary, lang) for _, b := range blocks { lines = append(lines, wrapText(b, width)) } return strings.TrimRight(strings.Join(lines, "\n"), "\n") } // renderCompare handles -c/--compare LIST: LIST is a comma-separated list of // version codes (falling back to cfg.Versions when empty), rendered side by // side via render.Compare. func renderCompare(cfg config.Config, list, date string, all, raw bool, width int, refresh bool, stdout, stderr io.Writer) int { var versions []string if list == "" { versions = append(versions, cfg.Versions...) } else { for _, v := range strings.Split(list, ",") { v = strings.TrimSpace(v) if v == "" { continue } versions = append(versions, v) } } for _, v := range versions { if !config.ValidVersion(v) { fmt.Fprintf(stderr, "lectio: unknown version %q (want one of pl, wuj, vul, grb, drb)\n", v) return 2 } } versions = render.EffectiveVersions(versions, cfg.Lectionary, cfg.Offline) secs, err := readings.Load(cfg, readings.Options{ Date: date, Refresh: refresh, Offline: cfg.Offline, All: all, }) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } if len(secs) == 0 { fmt.Fprintln(stderr, "lectio: no readings found for", date) return 1 } w := resolveWidth(width, true, stdout) if !raw { banner := bannerFor(cfg.UILanguage, all, date) fmt.Fprintln(stdout, banner) fmt.Fprintln(stdout, strings.Repeat("=", minInt(len(banner), w))) fmt.Fprintln(stdout) } fmt.Fprintln(stdout, render.Compare(secs, versions, w, cfg.Lectionary, cfg.UILanguage)) return 0 } // resolveWidth applies the --width 0 = detect-terminal rule: an explicit // positive width wins, then a real terminal width if stdout is a tty, then // a sane per-mode default. func resolveWidth(flagWidth int, isCompare bool, out io.Writer) int { if flagWidth > 0 { return flagWidth } if tw := termWidth(out); tw > 0 { return tw } if isCompare { return compareDefaultWidth } return defaultWidth } // termWidth checks $COLUMNS first (a common shell/CLI convention), then // falls back to a TIOCGWINSZ ioctl when out is a real terminal file. It // returns 0 (meaning: use the caller's default) when neither works. func termWidth(out io.Writer) int { if v := os.Getenv("COLUMNS"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { return n } } f, ok := out.(*os.File) if !ok { return 0 } return ttyWidth(f) } // wrapText greedily wraps line to width without breaking words, matching // ewangelia.py's textwrap.fill(..., break_long_words=False, // break_on_hyphens=False). Duplicated (in miniature) from render.wrap, // which is unexported: cli needs the same wrapping for single-version // output, render.Compare does its own internally. func wrapText(line string, width int) string { words := strings.Fields(line) if len(words) == 0 { return "" } var out []string cur := words[0] for _, word := range words[1:] { if len([]rune(cur))+1+len([]rune(word)) <= width { cur += " " + word } else { out = append(out, cur) cur = word } } out = append(out, cur) return strings.Join(out, "\n") } func minInt(a, b int) int { if a < b { return a } return b }