diff --git a/internal/cli/interactive.go b/internal/cli/interactive.go index bf3d05c..27d1a50 100644 --- a/internal/cli/interactive.go +++ b/internal/cli/interactive.go @@ -625,22 +625,47 @@ func promptCategorySpecific(p *ui.Printer, pr prompter, a *runDataIngestArgs) (b // named "label" if present; otherwise — the header isn't readable yet — it falls // back to free text pre-filled with the supplied value so the flow never stalls. // -// supplied is guarded through defaultInOptions before it reaches survey.Select: -// a mistyped --label-column that is not one of the real headers would otherwise -// abort the prompt on a TTY, the same default-not-in-options crash guarded -// everywhere else in the guided flow. +// supplied is resolved to the header's own spelling (canonicalHeader) and then +// guarded through defaultInOptions before it reaches survey.Select: a mistyped +// --label-column that is not one of the real headers would otherwise abort the +// prompt on a TTY, the same default-not-in-options crash guarded everywhere +// else in the guided flow. func promptLabelColumn(pr prompter, category, root, question, supplied string) (string, error) { headers, err := push.PreviewLabelHeaders(category, root) if err == nil && len(headers) > 0 { ans, serr := pr.Select(question, "pick the label/target column from your CSV header", headers, - defaultInOptions(supplied, headers, defaultLabelChoice(headers))) + defaultInOptions(canonicalHeader(supplied, headers), headers, defaultLabelChoice(headers))) return strings.TrimSpace(ans), serr } ans, ierr := pr.Input(question, "the label/target column name", supplied, nil) return strings.TrimSpace(ans), ierr } +// canonicalHeader returns the header that matches want case-insensitively, so a +// --label-column differing from the CSV only in case pre-selects the real column +// instead of silently falling back to the header default. +// +// Why this is needed at all: defaultInOptions matches EXACTLY, and until #505 a +// supplied --label-column skipped the prompt entirely, so its spelling was kept +// verbatim and never had to agree with the header. Now that the question is +// always asked, an unresolved value falls through to defaultLabelChoice — the +// FIRST column when nothing is named "label" — and Enter accepts that wrong +// column. Resolving here (rather than loosening defaultInOptions, which also +// guards --intent and --label-policy against fixed vocabularies) keeps the +// case-insensitivity where the options are user data. +// +// Returns want unchanged when nothing matches, leaving defaultInOptions to apply +// the fallback: a genuine typo must still be caught, not case-folded into a hit. +func canonicalHeader(want string, headers []string) string { + for _, h := range headers { + if strings.EqualFold(h, want) { + return h + } + } + return want +} + // defaultLabelChoice pre-highlights a column literally named "label" // (case-insensitive) when one exists, else the first column — a sensible // starting point for the SELECT. diff --git a/internal/cli/interactive_test.go b/internal/cli/interactive_test.go index 56a5ff2..e96a259 100644 --- a/internal/cli/interactive_test.go +++ b/internal/cli/interactive_test.go @@ -700,7 +700,9 @@ func TestRunInteractive_SuppliedLabelColumnStillAsks(t *testing.T) { // // Mutation-proof: passing `supplied` straight to pr.Select (dropping the guard) // makes the "mistyped" row error under the strict fake; not threading the value -// at all makes the "valid-supplied" row return the header default instead. +// at all makes the "valid-supplied" row return the header default instead; +// dropping canonicalHeader (resolving case) reddens the case-mismatch rows, +// which resolve to "age" — the first column — exactly as the defect did. func TestPromptLabelColumn_SuppliedDefaultPrefillsAndGuards(t *testing.T) { dir := tabularDir(t) // header: age,income,churned (no column named "label") const cat = "tabular_classification" @@ -714,6 +716,14 @@ func TestPromptLabelColumn_SuppliedDefaultPrefillsAndGuards(t *testing.T) { {"valid-supplied-is-preselected", "income", "income"}, {"empty-supplied-uses-header-default", "", "age"}, // defaultLabelChoice → first header {"mistyped-supplied-falls-back", "incom", "age"}, // guarded, no crash + + // Case-only differences must bind the HEADER's spelling, not fall back. + // Before the canonicalHeader resolve these landed on "age": the exact + // match failed, and with no column named "label" defaultLabelChoice + // returns the first column — which Enter then silently accepts as the + // label. That is the whole defect, so these rows are the regression. + {"case-mismatch-supplied-resolves", "Income", "income"}, + {"case-mismatch-uppercase-resolves", "CHURNED", "churned"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) {