textmachine/backend/cmd/tmctl/invocation.go

162 lines
9 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
// verifyBank is the bank-verification MODE (pack-20 / D39.42 п.5): stop at the bank-mining boundary
// and show the table instead of carrying an unsigned bank into the edit wave. CLI-only and BINARY —
// «флаг бинарный» (owner) — and deliberately not a book.yaml key: whether a human is available to
// sign right now is a property of the invocation, not of the book.
verifyBank bool
// ceilingUSD is the BOOK ceiling for THIS RUN ONLY (row 145 / D39.110), 0 when the flag was absent.
// It is an invocation property for the same reason verifyBank is: the amount a caller is willing to
// spend on one run belongs to that run, not to the book's data — writing it into book.yaml would make
// a caller's number a permanent record in the engine's config and mix the zones (D39.81/D39.85).
ceilingUSD float64
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|manifest> --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; manifest: emit the manifest document instead of a summary")
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")
verifyBank := fs.Bool("verify-bank", false, "translate/redrive: STOP at the bank-mining boundary and print the bank table for review instead of continuing into the edit wave with an unsigned bank (default: continue)")
ceilingUSD := fs.Float64("ceiling-usd", 0, "translate/redrive: the book USD ceiling in force for THIS RUN ONLY — it OVERRIDES book.yaml `ceilings.book_usd` and is never written back. It caps the book's CUMULATIVE committed+reserved spend, not this run's increment, and must be > 0")
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 run ceiling is refused rather than defaulted when it is present but not a spendable amount, so
// the flag can only ever ADD a bound, never remove one (D39.110: «выключено» is not a state).
//
// Р7's own validator (config.LoadBook) is untouched and is NOT the same rule: it demands at least ONE
// of book_usd/day_usd, so a book may legitimately declare a daily ceiling only — and then the book
// ceiling this flag overrides is zero, i.e. absent, and passing the flag introduces one for this run.
// fs.Visit reports only flags actually PRESENT on the command line, which is what separates
// "--ceiling-usd 0" (a refusal) from an absent flag (the book's own ceiling stands). Placed AFTER the
// --config check so the frozen validation order is untouched: a missing --config is still the first
// thing a caller is told about.
ceilingGiven := false
fs.Visit(func(f *flag.Flag) {
if f.Name == "ceiling-usd" {
ceilingGiven = true
}
})
if ceilingGiven && (math.IsNaN(*ceilingUSD) || math.IsInf(*ceilingUSD, 0) || *ceilingUSD <= 0) {
return invocation{}, fmt.Errorf("--ceiling-usd must be a finite amount greater than zero (it is the ceiling for THIS run and overrides book.yaml ceilings.book_usd); got %v — omit the flag to run under the book's own ceiling", *ceilingUSD)
}
// 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, verifyBank: *verifyBank,
ceilingUSD: *ceilingUSD,
sel: pipeline.RedriveSelector{
Chapter: *chapter, ChunkIdx: *chunk, Reason: *reason, DryRun: *dryRun,
},
}, nil
}