textmachine/backend/cmd/tmctl/invocation.go

130 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"flag"
"fmt"
"io"
"math"
"strconv"
"textmachine/backend/internal/pipeline"
)
// invocation.go: CLI argument parsing, extracted into a pure function (package №4 —
// tmctl had 0 tests for the contract invariants: bad-flag→exit 1, not 2;
// the redrive selector; validation order). The flag/error-text contract is FROZEN
// (D12/D15.3) — this function merely relocates it to a testable place.
// invocation is one parsed tmctl command line.
type invocation struct {
cmd string
cfgPath string
seedPath string // seed-lint: the glossary seed YAML to validate (no --config)
resnapshot bool
acceptRebill pipeline.RebillConsent // --accept-rebill[=usd]: Р6 consent to a projected re-payment
asJSON bool
asPlaintext bool
asPairs bool // export: include the source column (--pairs) for the DC1/DC2 FP-measure
sel pipeline.RedriveSelector
}
// rebillConsentValue parses `--accept-rebill[=usd]` (D20.2-Q2): the OPTIONAL-VALUE form of Р6, where a
// bare flag consents to the whole projected re-payment and `--accept-rebill=1.50` consents only while
// the projection stays at or below $1.50. It is a flag.Value with IsBoolFlag, which is the only way the
// stdlib grants a flag an optional value: bare, the package calls Set("true").
type rebillConsentValue struct{ c pipeline.RebillConsent }
func (v *rebillConsentValue) String() string {
switch {
case v == nil || !v.c.Given:
return "false"
case v.c.Capped:
return strconv.FormatFloat(v.c.CapUSD, 'g', -1, 64)
default:
return "true"
}
}
func (v *rebillConsentValue) Set(s string) error {
switch s {
case "true":
v.c = pipeline.RebillConsent{Given: true}
return nil
case "false":
v.c = pipeline.RebillConsent{}
return nil
}
usd, err := strconv.ParseFloat(s, 64)
if err != nil || math.IsNaN(usd) || math.IsInf(usd, 0) || usd < 0 {
return fmt.Errorf("--accept-rebill takes a non-negative USD ceiling (--accept-rebill=1.50) or no value at all; got %q", s)
}
v.c = pipeline.RebillConsent{Given: true, Capped: true, CapUSD: usd}
return nil
}
// IsBoolFlag lets `--accept-rebill` stand alone. The cost of that stdlib affordance is that
// `--accept-rebill 1.50` does NOT bind the amount — the flag reads as bare and 1.50 becomes a stray
// argument — which would silently turn a capped consent into an unlimited one. parseInvocation refuses
// that spelling explicitly rather than let it read as consent to everything.
func (v *rebillConsentValue) IsBoolFlag() bool { return true }
// parseInvocation parses os.Args[1:] into an invocation. flagOut receives the
// stdlib's flag diagnostics ("flag provided but not defined" + usage) — main
// passes os.Stderr, tests a buffer; the bytes and their destination are part of
// the frozen contract.
//
// Deliberately preserved quirks (changing them = changing the contract):
// - the command NAME is not validated here — `tmctl bogus --config x` reaches
// the dispatch switch (after dotenv/ctx setup) and errors there, while
// `tmctl bogus` without --config errors "--config book.yaml is required";
// - ContinueOnError, NOT ExitOnError: the stdlib's ExitOnError would call
// os.Exit(2) on a bad flag and would COLLAPSE onto exit code 2
// (completed-with-flags); returning an error routes the bad flag into
// main()'s default branch → exit 1, leaving 2 exclusive to flagged chunks.
func parseInvocation(args []string, flagOut io.Writer) (invocation, error) {
if len(args) < 1 {
return invocation{}, fmt.Errorf("usage: tmctl <translate|report|status|export|redrive> --config book.yaml")
}
cmd, rest := args[0], args[1:]
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
fs.SetOutput(flagOut)
cfgPath := fs.String("config", "", "path to book.yaml")
resnapshot := fs.Bool("resnapshot", false, "re-pin existing jobs to the current config snapshot (re-translates already-paid chunks — explicit consent)")
acceptRebill := &rebillConsentValue{}
fs.Var(acceptRebill, "accept-rebill", "translate/redrive: consent to the projected RE-PAYMENT of already-billed work (D20.2-Q2). Bare accepts the whole projected amount; --accept-rebill=1.50 accepts it only up to $1.50")
asJSON := fs.Bool("json", false, "status: emit the projection as JSON (stable disposition/flag_reason enums) for CI/IDE")
asPlaintext := fs.Bool("plaintext", false, "export: emit the concatenated human text instead of the default stable JSON")
asPairs := fs.Bool("pairs", false, "export: include the source text per chunk (src↔target column for the DC1/DC2 FP-measure, WS5)")
chapter := fs.Int("chapter", -1, "redrive: restrict to this chapter (default: any)")
chunk := fs.Int("chunk", -1, "redrive: restrict to this chunk index within the chapter (default: any)")
reason := fs.String("reason", "", "redrive: restrict to this flag_reason (default: any)")
dryRun := fs.Bool("dry-run", false, "redrive: report what would be re-attacked without touching anything")
seed := fs.String("seed", "", "seed-lint: path to the glossary seed YAML to validate ($0, no --config)")
if err := fs.Parse(rest); err != nil {
return invocation{}, err
}
// seed-lint validates a standalone seed YAML — it takes --seed, not --config (no book/store/keys).
if cmd == "seed-lint" {
if *seed == "" {
return invocation{}, fmt.Errorf("--seed <glossary.yaml> is required for seed-lint")
}
return invocation{cmd: cmd, seedPath: *seed}, nil
}
if *cfgPath == "" {
return invocation{}, fmt.Errorf("--config book.yaml is required")
}
// The optional-value trap (see IsBoolFlag): `--accept-rebill 1.50` leaves 1.50 as a stray argument
// and the consent unlimited. Refuse loud rather than bill the difference. Scoped to exactly that
// spelling, so the historically-tolerated stray argument stays tolerated everywhere else.
if acceptRebill.c.Given && !acceptRebill.c.Capped && fs.NArg() > 0 {
return invocation{}, fmt.Errorf("--accept-rebill takes its ceiling with an «=» (--accept-rebill=%s), not a space: as written %q is a stray argument and the flag consents to the FULL projected re-payment", fs.Arg(0), fs.Arg(0))
}
return invocation{
cmd: cmd, cfgPath: *cfgPath, resnapshot: *resnapshot, acceptRebill: acceptRebill.c,
asJSON: *asJSON, asPlaintext: *asPlaintext, asPairs: *asPairs,
sel: pipeline.RedriveSelector{
Chapter: *chapter, ChunkIdx: *chunk, Reason: *reason, DryRun: *dryRun,
},
}, nil
}