From b3e5db93f7b59c7b80706ae30b505cb6b7945ec3 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 23 Jul 2026 13:47:25 +0200 Subject: cli: subcommand dispatch --- internal/cli/cli.go | 472 +++++++++++++++++++++++++++++++++++++++- internal/cli/cli_test.go | 51 +++++ internal/cli/termwidth_other.go | 11 + internal/cli/termwidth_unix.go | 23 ++ 4 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 internal/cli/cli_test.go create mode 100644 internal/cli/termwidth_other.go create mode 100644 internal/cli/termwidth_unix.go (limited to 'internal/cli') 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 -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) +} -- cgit v1.3