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 --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 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 }