Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions internal/cli/interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion internal/cli/interactive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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) {
Expand Down
Loading