// 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" ) // versionString is printed by --version/-v. const versionString = "0.1.0" 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 -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}$`) var validVersions = map[string]bool{ "pl": true, "wuj": true, "vul": true, "grb": true, "drb": true, } 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 len(args) > 0 && (args[0] == "--version" || args[0] == "-v") { fmt.Fprintln(stdout, "lectio "+versionString) 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 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") 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 compareList != "" { return renderCompare(cfg, compareList, date, effAll, raw, effWidth, refresh, stdout, stderr) } if bibleVer != "" { if !validVersions[bibleVer] { fmt.Fprintf(stderr, "lectio: unknown version %q (want one of pl, wuj, vul, grb, drb)\n", bibleVer) return 2 } return fetchAndPrint(cfg, bibleVer, date, effAll, raw, effWidth, refresh, stdout, stderr) } return fetchAndPrint(cfg, cfg.DefaultVersion, date, effAll, raw, effWidth, refresh, stdout, stderr) } // 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 } // 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"); "" (flag not given) passes through unchanged so // the caller knows to leave config's own setting alone. func normalizeLectionary(lectionary string) (string, error) { switch lectionary { case "": return "", nil case "trad": return "traditional", nil case "new", "traditional": return lectionary, nil default: return "", fmt.Errorf("invalid --lectionary %q (want new|trad)", lectionary) } } // runHarvest handles -u/--update: harvest sigla maximally (to the // unpublished horizon) from date, printing the outcome or -- on a genuine // interruption (see liturgy.Harvest) -- the error. 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 } fmt.Fprintf(stdout, "harvested %d day(s), furthest %s\n", added, furthest) 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. 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 cfg.Offline { if vs := render.OfflineVersions([]string{version}); 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)) if sec.Subtitle != "" { lines = append(lines, sec.Subtitle) } 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 !validVersions[v] { fmt.Fprintf(stderr, "lectio: unknown version %q (want one of pl, wuj, vul, grb, drb)\n", v) return 2 } } if cfg.Offline { versions = render.OfflineVersions(versions) } 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 }