78 lines
3.7 KiB
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
|
|
}
|