162 lines
9 KiB
Go
162 lines
9 KiB
Go
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
|
||
}
|