textmachine/backend/cmd/tmctl/invocation.go

78 lines
3.7 KiB
Go

package main
import (
"flag"
"fmt"
"io"
"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
asJSON bool
asPlaintext bool
asPairs bool // export: include the source column (--pairs) for the DC1/DC2 FP-measure
sel pipeline.RedriveSelector
}
// 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)")
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")
}
return invocation{
cmd: cmd, cfgPath: *cfgPath, resnapshot: *resnapshot, asJSON: *asJSON, asPlaintext: *asPlaintext, asPairs: *asPairs,
sel: pipeline.RedriveSelector{
Chapter: *chapter, ChunkIdx: *chunk, Reason: *reason, DryRun: *dryRun,
},
}, nil
}