summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-23 13:47:25 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-23 13:47:25 +0200
commitb3e5db93f7b59c7b80706ae30b505cb6b7945ec3 (patch)
treef42c52132b49b58540ef78be68cf0bef7479cab5
parentcb1f49117d15b9690b46f344c0d6777e558b9021 (diff)
downloadlectio-b3e5db93f7b59c7b80706ae30b505cb6b7945ec3.tar.gz
lectio-b3e5db93f7b59c7b80706ae30b505cb6b7945ec3.zip
cli: subcommand dispatch
-rw-r--r--internal/cli/cli.go472
-rw-r--r--internal/cli/cli_test.go51
-rw-r--r--internal/cli/termwidth_other.go11
-rw-r--r--internal/cli/termwidth_unix.go23
4 files changed, 556 insertions, 1 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index b203c44..66f45a7 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -1,12 +1,482 @@
+// Package cli is lectio's subcommand 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/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 [today] [--all] [--raw] [--width N] [--refresh]
+ lectio date D [--all] [--raw] [--width N] [--refresh] D = YYYY-MM-DD
+ lectio show VERSION [--date D] [--all] [--raw] [--width N] [--refresh]
+ one version's text
+ lectio compare LIST [--date D] [--all] [--width N] [--refresh] [--raw]
+ LIST = comma-separated versions (default: config "versions")
+ lectio update [--days N] [--from D] harvest future sigla to the TSV
+ lectio --version | lectio -v
+ lectio help | lectio -h | lectio <cmd> -h
+
+Global flags (any subcommand, any position):
+ --offline skip the network, use cached/harvested data only
+ --lectionary new|traditional override config "lectionary" for this run
+ --lang pl|en override config "traditional_lang" for this run
+
+Per-command flags:
+ --all show every reading part (1st/2nd, psalm, acclamation,
+ gospel); default is the gospel only, unless config
+ all = true
+ --raw omit the banner/headings (for piping)
+ --width N wrap width; 0 = detect terminal, else a sane default
+ (80 normally, 120 for compare)
+ --refresh bypass the cache and re-fetch
+ --date D date as YYYY-MM-DD (show/compare; default: today)
+
+Versions: pl (Polski/niedziela.pl) wuj (Wujek) vul (Wulgata) grb (Grecki)
+ drb (Douay-Rheims)
+
+Flags override config. Exit codes: 0 ok, 1 runtime error (fetch/parse),
+2 usage error (unknown command/version, bad date, missing argument).
+`
+
+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 {
- fmt.Fprintln(stdout, "lectio: not yet implemented")
+ 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
+ }
+
+ offline, lectionary, lang, rest := extractGlobal(args)
+ if lectionary != "" && lectionary != "new" && lectionary != "traditional" {
+ fmt.Fprintf(stderr, "lectio: invalid --lectionary %q (want new|traditional)\n", lectionary)
+ return 2
+ }
+ if lang != "" && lang != "pl" && lang != "en" {
+ fmt.Fprintf(stderr, "lectio: invalid --lang %q (want pl|en)\n", lang)
+ return 2
+ }
+
+ cmd := "today"
+ cmdArgs := rest
+ if len(rest) > 0 && !strings.HasPrefix(rest[0], "-") {
+ cmd = rest[0]
+ cmdArgs = rest[1:]
+ }
+
+ switch cmd {
+ case "today", "date", "show", "compare":
+ 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
+ }
+ switch cmd {
+ case "today":
+ return runToday(cmdArgs, cfg, stdout, stderr)
+ case "date":
+ return runDate(cmdArgs, cfg, stdout, stderr)
+ case "show":
+ return runShow(cmdArgs, cfg, stdout, stderr)
+ case "compare":
+ return runCompare(cmdArgs, cfg, stdout, stderr)
+ }
+ return 1 // unreachable
+ case "update":
+ return runUpdate(cmdArgs, stdout, stderr)
+ default:
+ fmt.Fprintf(stderr, "lectio: unknown command %q; see 'lectio help'\n", cmd)
+ return 2
+ }
+}
+
+// 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
+}
+
+// extractGlobal pulls the global --offline/--lectionary/--lang flags out of
+// args (they may appear anywhere, before or after the subcommand), leaving
+// rest for subcommand dispatch and per-command flag parsing.
+func extractGlobal(args []string) (offline bool, lectionary, lang string, rest []string) {
+ for i := 0; i < len(args); i++ {
+ a := args[i]
+ switch {
+ case a == "--offline":
+ offline = true
+ case a == "--lectionary" && i+1 < len(args):
+ lectionary = args[i+1]
+ i++
+ case strings.HasPrefix(a, "--lectionary="):
+ lectionary = strings.TrimPrefix(a, "--lectionary=")
+ case a == "--lang" && i+1 < len(args):
+ lang = args[i+1]
+ i++
+ case strings.HasPrefix(a, "--lang="):
+ lang = strings.TrimPrefix(a, "--lang=")
+ default:
+ rest = append(rest, a)
+ }
+ }
+ return offline, lectionary, lang, rest
+}
+
+// runToday handles the (default) "today" subcommand: --all --raw --width
+// --refresh, date = today.
+func runToday(args []string, cfg config.Config, stdout, stderr io.Writer) int {
+ fs := flag.NewFlagSet("today", flag.ContinueOnError)
+ fs.SetOutput(stderr)
+ all := fs.Bool("all", cfg.All, "show every reading part, not just the gospel")
+ raw := fs.Bool("raw", false, "omit banner/headings (for piping)")
+ width := fs.Int("width", cfg.Width, "wrap width (0 = detect terminal)")
+ refresh := fs.Bool("refresh", false, "bypass cache, re-fetch")
+ if err := fs.Parse(args); err != nil {
+ return 2
+ }
+ if fs.NArg() > 0 {
+ fmt.Fprintf(stderr, "lectio: today takes no date argument (use: lectio date %s)\n", fs.Arg(0))
+ return 2
+ }
+ return fetchAndPrint(cfg, cfg.DefaultVersion, today(), *all, *raw, *width, *refresh, stdout, stderr)
+}
+
+// runDate handles "date D": D is the required first positional argument,
+// flags as in runToday.
+func runDate(args []string, cfg config.Config, stdout, stderr io.Writer) int {
+ if len(args) == 0 {
+ fmt.Fprintln(stderr, "lectio: date requires an argument (YYYY-MM-DD)")
+ return 2
+ }
+ date := args[0]
+ if !dateRe.MatchString(date) {
+ fmt.Fprintf(stderr, "lectio: invalid date %q (want YYYY-MM-DD)\n", date)
+ return 2
+ }
+ fs := flag.NewFlagSet("date", flag.ContinueOnError)
+ fs.SetOutput(stderr)
+ all := fs.Bool("all", cfg.All, "show every reading part, not just the gospel")
+ raw := fs.Bool("raw", false, "omit banner/headings (for piping)")
+ width := fs.Int("width", cfg.Width, "wrap width (0 = detect terminal)")
+ refresh := fs.Bool("refresh", false, "bypass cache, re-fetch")
+ if err := fs.Parse(args[1:]); err != nil {
+ return 2
+ }
+ return fetchAndPrint(cfg, cfg.DefaultVersion, date, *all, *raw, *width, *refresh, stdout, stderr)
+}
+
+// runShow handles "show VERSION": same as today/date but the version is
+// fixed by the caller instead of cfg.DefaultVersion.
+func runShow(args []string, cfg config.Config, stdout, stderr io.Writer) int {
+ if len(args) == 0 {
+ fmt.Fprintln(stderr, "lectio: show requires a version argument (pl|wuj|vul|grb|drb)")
+ return 2
+ }
+ ver := args[0]
+ if !validVersions[ver] {
+ fmt.Fprintf(stderr, "lectio: unknown version %q (want one of pl, wuj, vul, grb, drb)\n", ver)
+ return 2
+ }
+ fs := flag.NewFlagSet("show", flag.ContinueOnError)
+ fs.SetOutput(stderr)
+ all := fs.Bool("all", cfg.All, "show every reading part, not just the gospel")
+ raw := fs.Bool("raw", false, "omit banner/headings (for piping)")
+ width := fs.Int("width", cfg.Width, "wrap width (0 = detect terminal)")
+ refresh := fs.Bool("refresh", false, "bypass cache, re-fetch")
+ date := fs.String("date", today(), "date as YYYY-MM-DD")
+ if err := fs.Parse(args[1:]); err != nil {
+ return 2
+ }
+ if !dateRe.MatchString(*date) {
+ fmt.Fprintf(stderr, "lectio: invalid date %q (want YYYY-MM-DD)\n", *date)
+ return 2
+ }
+ return fetchAndPrint(cfg, ver, *date, *all, *raw, *width, *refresh, stdout, stderr)
+}
+
+// fetchAndPrint is the shared today/date/show path: 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(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, w, raw))
+ }
+ fmt.Fprintln(stdout, strings.Join(pieces, "\n\n"))
return 0
}
+
+// bannerFor mirrors ewangelia.py's banner text: "Czytania na D" when every
+// part is shown, "Ewangelia na D" for the gospel-only default.
+func bannerFor(all bool, date string) string {
+ if all {
+ return "Czytania na " + date
+ }
+ return "Ewangelia na " + date
+}
+
+// renderSection formats one section as its heading (unless raw) followed by
+// render.GatherVersion's blocks, each wrapped to width.
+func renderSection(sec liturgy.Section, version, lectionary string, width int, raw bool) string {
+ var lines []string
+ if !raw {
+ lines = append(lines, sec.Heading)
+ if sec.Subtitle != "" {
+ lines = append(lines, sec.Subtitle)
+ }
+ lines = append(lines, "")
+ }
+ _, blocks := render.GatherVersion(version, sec, lectionary)
+ for _, b := range blocks {
+ lines = append(lines, wrapText(b, width))
+ }
+ return strings.TrimRight(strings.Join(lines, "\n"), "\n")
+}
+
+// runCompare handles "compare LIST": LIST is the required first positional
+// argument (falling back to cfg.Versions when omitted entirely).
+func runCompare(args []string, cfg config.Config, stdout, stderr io.Writer) int {
+ var list string
+ rest := args
+ if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
+ list = args[0]
+ rest = args[1:]
+ }
+
+ fs := flag.NewFlagSet("compare", flag.ContinueOnError)
+ fs.SetOutput(stderr)
+ all := fs.Bool("all", cfg.All, "show every reading part, not just the gospel")
+ raw := fs.Bool("raw", false, "omit banner/headings (for piping)")
+ width := fs.Int("width", cfg.Width, "wrap width (0 = detect terminal; compare default 120)")
+ refresh := fs.Bool("refresh", false, "bypass cache, re-fetch")
+ date := fs.String("date", today(), "date as YYYY-MM-DD")
+ if err := fs.Parse(rest); err != nil {
+ return 2
+ }
+ if !dateRe.MatchString(*date) {
+ fmt.Fprintf(stderr, "lectio: invalid date %q (want YYYY-MM-DD)\n", *date)
+ return 2
+ }
+
+ 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(*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))
+ return 0
+}
+
+// runUpdate handles "update [--days N] [--from D]": it does not touch
+// config -- liturgy.Harvest owns its own cache/sigla-store paths.
+func runUpdate(args []string, stdout, stderr io.Writer) int {
+ fs := flag.NewFlagSet("update", flag.ContinueOnError)
+ fs.SetOutput(stderr)
+ days := fs.Int("days", 0, "max days to harvest (0 = until the unpublished horizon)")
+ from := fs.String("from", today(), "start date as YYYY-MM-DD (default: today)")
+ if err := fs.Parse(args); err != nil {
+ return 2
+ }
+ if !dateRe.MatchString(*from) {
+ fmt.Fprintf(stderr, "lectio: invalid date %q (want YYYY-MM-DD)\n", *from)
+ return 2
+ }
+
+ added, furthest, err := liturgy.Harvest(*from, *days)
+ if err != nil {
+ fmt.Fprintln(stderr, "lectio:", err)
+ return 1
+ }
+ fmt.Fprintf(stdout, "harvested %d day(s), furthest %s\n", added, furthest)
+ 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
+}
diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go
new file mode 100644
index 0000000..85097ed
--- /dev/null
+++ b/internal/cli/cli_test.go
@@ -0,0 +1,51 @@
+package cli
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+func TestHelp(t *testing.T) {
+ var out, errb bytes.Buffer
+ code := Run([]string{"help"}, nil, &out, &errb)
+ if code != 0 || !strings.Contains(out.String(), "lectio") {
+ t.Errorf("help code=%d out=%q", code, out.String())
+ }
+}
+
+func TestUnknownCommand(t *testing.T) {
+ var out, errb bytes.Buffer
+ if code := Run([]string{"bogus"}, nil, &out, &errb); code != 2 {
+ t.Errorf("unknown cmd code=%d want 2", code)
+ }
+}
+
+func TestVersion(t *testing.T) {
+ var out, errb bytes.Buffer
+ if code := Run([]string{"--version"}, nil, &out, &errb); code != 0 {
+ t.Errorf("version code=%d", code)
+ }
+}
+
+// TestBadDate exercises the "date" subcommand's usage-error path (bad
+// YYYY-MM-DD format) without touching the network: format validation
+// happens before any fetch is attempted.
+func TestBadDate(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ var out, errb bytes.Buffer
+ if code := Run([]string{"date", "13-13-13"}, nil, &out, &errb); code != 2 {
+ t.Errorf("bad date code=%d want 2 (stderr=%q)", code, errb.String())
+ }
+}
+
+// TestUnknownVersion exercises "show"'s version-validation usage-error path
+// without touching the network: the version code is checked against the
+// five known codes before any fetch is attempted.
+func TestUnknownVersion(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ var out, errb bytes.Buffer
+ if code := Run([]string{"show", "zzz"}, nil, &out, &errb); code != 2 {
+ t.Errorf("unknown version code=%d want 2 (stderr=%q)", code, errb.String())
+ }
+}
diff --git a/internal/cli/termwidth_other.go b/internal/cli/termwidth_other.go
new file mode 100644
index 0000000..a44fc70
--- /dev/null
+++ b/internal/cli/termwidth_other.go
@@ -0,0 +1,11 @@
+//go:build !unix
+
+package cli
+
+import "os"
+
+// ttyWidth has no portable implementation outside unix; callers fall back
+// to the $COLUMNS env var or a sane default.
+func ttyWidth(f *os.File) int {
+ return 0
+}
diff --git a/internal/cli/termwidth_unix.go b/internal/cli/termwidth_unix.go
new file mode 100644
index 0000000..9d1b47d
--- /dev/null
+++ b/internal/cli/termwidth_unix.go
@@ -0,0 +1,23 @@
+//go:build unix
+
+package cli
+
+import (
+ "os"
+ "syscall"
+ "unsafe"
+)
+
+// ttyWidth returns f's terminal column width via the TIOCGWINSZ ioctl, or 0
+// if f isn't a terminal (redirected to a file/pipe, as in tests) or the
+// ioctl fails.
+func ttyWidth(f *os.File) int {
+ var ws struct {
+ Row, Col, Xpixel, Ypixel uint16
+ }
+ _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), syscall.TIOCGWINSZ, uintptr(unsafe.Pointer(&ws)))
+ if errno != 0 || ws.Col == 0 {
+ return 0
+ }
+ return int(ws.Col)
+}