From ba8d92b0ae04f97f28606c09a760ca3632da7a37 Mon Sep 17 00:00:00 2001 From: Lukas Wuttke Date: Fri, 10 Jul 2026 11:04:42 +0200 Subject: [PATCH 1/2] fix(data ingest): warn instead of silently ingesting a mis-cased media folder as a table (#203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dataset with labels.csv plus a mis-cased media folder (Images/, Texts/, Sequences/) already stays ambiguous rather than sniffing confident tabular (#198's miscasedMarker guard) — but the ambiguous path gave no clue why. So the user saw only a blind "which is it?" prompt with no hint that their folder was just mis-cased. Carry a plain-language hint on the ambiguous FamilySniff for that case, naming the folder and the lowercase form it should have (e.g. "Images" → "images"), and surface it via ui.Printer.Warnf in resolveFamily before the family question. Reuses the same marker set the case-sensitive walk keys on (markerFold, formerly isMarkerFold, now returns the canonical marker). Extend TestSniffFamily to cover all three mis-cased markers (adds the Sequences/ case), assert each stays ambiguous AND carries a hint naming the rename, and pin that an unrelated subdir stays confident tabular with no hint. Co-Authored-By: Claude Opus 4.8 --- internal/cli/interactive.go | 9 ++++- internal/push/preview.go | 43 ++++++++++++++++++---- internal/push/preview_test.go | 69 +++++++++++++++++++++-------------- 3 files changed, 85 insertions(+), 36 deletions(-) diff --git a/internal/cli/interactive.go b/internal/cli/interactive.go index 70c1e279..fda85562 100644 --- a/internal/cli/interactive.go +++ b/internal/cli/interactive.go @@ -208,10 +208,17 @@ func runInteractive(p *ui.Printer, pr prompter, a *runDataIngestArgs, taskSet bo // prompts for the task afterward, so resolveFamily needn't report whether // it prompted the family question.) func resolveFamily(p *ui.Printer, pr prompter, path string) (push.Family, error) { - if s := push.SniffFamily(path); s.Confident { + s := push.SniffFamily(path) + if s.Confident { p.Successf("%s", s.Echo) return s.Family, nil } + if s.Hint != "" { + // Advisory only — e.g. a mis-cased media folder the walk won't see + // (#203). We still ask the family question; the hint just tells the + // user what looks off so they can fix the layout. + p.Warnf("%s", s.Hint) + } p.PromptHint("We couldn't tell the data type from what's there — which is it?") opts := push.FamilyNouns() ans, err := pr.Select("What kind of data is this?", diff --git a/internal/push/preview.go b/internal/push/preview.go index f1248524..706b51f3 100644 --- a/internal/push/preview.go +++ b/internal/push/preview.go @@ -25,6 +25,11 @@ type FamilySniff struct { // e.g. "Found a CSV table — this is tabular data." Empty when not // confident. Echo string + // Hint is an optional plain-language note for the ambiguous case, e.g. + // pointing out a mis-cased media folder ("Images/") that the walk won't + // see. Purely advisory — it never makes the sniff confident; the caller + // still asks the family question. Empty when there's nothing to add. + Hint string } // SniffFamily previews the family of the dataset at path by looking for @@ -102,6 +107,10 @@ func SniffFamily(path string) FamilySniff { // ingested as a table, images/texts dropped. When we see one, stay // ambiguous and ask the family plainly. miscasedMarker := false + // miscasedName / miscasedCanonical hold the first mis-cased dir we see + // and the lowercase marker it resembles, so the ambiguous return can + // name the likely rename (e.g. "Images" → "images") in its Hint. + var miscasedName, miscasedCanonical string csvCount := 0 for _, e := range entries { name := e.Name() @@ -114,8 +123,14 @@ func SniffFamily(path string) FamilySniff { case "sequences": hasSequences = true default: - if isMarkerFold(name) { + // The exact lowercase markers are matched by the cases above, + // so anything that EqualFolds a marker here is case-insensitive + // but NOT exact — a likely mis-cased media folder. + if m := markerFold(name); m != "" { miscasedMarker = true + if miscasedName == "" { + miscasedName, miscasedCanonical = name, m + } } } continue @@ -160,22 +175,34 @@ func SniffFamily(path string) FamilySniff { // whose media folder was mis-cased must not masquerade as a table. return FamilySniff{Family: FamilyTabular, Confident: true, Echo: "Found a CSV table — this is tabular data."} + case miscasedMarker: + // A subdir matches a media marker case-insensitively but not exactly + // (e.g. "Images/"). The walk keys on the literal lowercase name, so it + // won't see this folder — an image/text layout here would otherwise + // look like a lone-CSV table and be silently ingested as one (#203). + // Stay ambiguous, but point at the likely rename so the user can fix + // the layout rather than just being asked a blind question. + return FamilySniff{Hint: fmt.Sprintf( + "Found a %q folder — image and text data use a lowercase folder like %q. "+ + "If that's your data folder, rename it and ingest again.", + miscasedName, miscasedCanonical)} default: return FamilySniff{} } } -// isMarkerFold reports whether name is one of the media-folder markers -// (images / texts / sequences) ignoring case. Used only to detect a -// mis-cased marker dir; the confident image/text branches still require an -// EXACT match, mirroring the walk's literal os.Lstat. -func isMarkerFold(name string) bool { +// markerFold returns the media-folder marker (images / texts / sequences) +// that name matches ignoring case, or "" if none. Used only to detect a +// mis-cased marker dir and name the correct lowercase form in the hint; the +// confident image/text branches still require an EXACT match, mirroring the +// walk's literal os.Lstat. +func markerFold(name string) string { for _, m := range []string{"images", "texts", "sequences"} { if strings.EqualFold(name, m) { - return true + return m } } - return false + return "" } // PreviewLabelHeaders returns the column names of the CSV a label column diff --git a/internal/push/preview_test.go b/internal/push/preview_test.go index dba65827..08802f15 100644 --- a/internal/push/preview_test.go +++ b/internal/push/preview_test.go @@ -3,6 +3,7 @@ package push import ( "os" "path/filepath" + "strings" "testing" ) @@ -91,34 +92,45 @@ func TestSniffFamily(t *testing.T) { } }) - t.Run("mis-cased Images/ + labels.csv is ambiguous, NOT confident tabular", func(t *testing.T) { - // Discover Lstats the literal "images"; a mis-cased "Images/" is not - // its marker. The sniff must not claim confident image — but it must - // ALSO not fall through to confident tabular, or the lone labels.csv - // of a mis-cased image layout would be silently ingested as a table - // (cli#203). It stays ambiguous so the flow asks the family plainly. - dir := t.TempDir() - writePrev(t, filepath.Join(dir, "labels.csv"), "image_id,label\n1.jpg,c\n") - if err := os.Mkdir(filepath.Join(dir, "Images"), 0o755); err != nil { - t.Fatal(err) - } - if s := SniffFamily(dir); s.Confident { - t.Fatalf("mis-cased Images/ + labels.csv must be ambiguous, got %+v", s) - } - }) - - t.Run("mis-cased Texts/ + labels.csv is ambiguous, NOT confident tabular", func(t *testing.T) { - dir := t.TempDir() - writePrev(t, filepath.Join(dir, "labels.csv"), "text_id,label\n1.txt,c\n") - if err := os.Mkdir(filepath.Join(dir, "Texts"), 0o755); err != nil { - t.Fatal(err) - } - if s := SniffFamily(dir); s.Confident { - t.Fatalf("mis-cased Texts/ + labels.csv must be ambiguous, got %+v", s) - } - }) + // The mis-cased-media footgun (#203): labels.csv next to a media folder + // whose name matches a marker case-insensitively but not exactly. The + // walk keys on the literal lowercase name, so it won't see the folder; + // without the guard the lone labels.csv falls through to confident + // tabular and the media is silently ingested away as a table. Each + // mis-cased marker (Images/, Texts/, Sequences/) must stay ambiguous — + // NOT confident image AND NOT confident tabular — and carry a hint that + // names the likely lowercase rename so the flow can guide the fix. + for _, tc := range []struct{ folder, canonical string }{ + {"Images", "images"}, + {"Texts", "texts"}, + {"Sequences", "sequences"}, + } { + tc := tc + t.Run("mis-cased "+tc.folder+"/ + labels.csv is ambiguous with a rename hint", func(t *testing.T) { + dir := t.TempDir() + writePrev(t, filepath.Join(dir, "labels.csv"), "id,label\n1,c\n") + if err := os.Mkdir(filepath.Join(dir, tc.folder), 0o755); err != nil { + t.Fatal(err) + } + s := SniffFamily(dir) + if s.Confident { + t.Fatalf("mis-cased %s/ + labels.csv must be ambiguous, got %+v", tc.folder, s) + } + // Regression: the real footgun is confident TABULAR, not just + // not-image. Pin that the family is not confidently claimed at all. + if s.Family == FamilyTabular && s.Confident { + t.Fatalf("mis-cased %s/ must NOT sniff as confident tabular, got %+v", tc.folder, s) + } + if s.Hint == "" { + t.Fatalf("mis-cased %s/ should carry a rename hint, got %+v", tc.folder, s) + } + if !strings.Contains(s.Hint, tc.folder) || !strings.Contains(s.Hint, tc.canonical) { + t.Fatalf("hint should name both %q and %q, got %q", tc.folder, tc.canonical, s.Hint) + } + }) + } - t.Run("single csv + unrelated subdir stays confident tabular", func(t *testing.T) { + t.Run("single csv + unrelated subdir stays confident tabular, no hint", func(t *testing.T) { // The mis-cased guard must be narrow: a subdir that is NOT a marker // name (case-insensitively) — a stray backup/ etc. — must not derail // the confident-tabular sniff, since DiscoverTabular ignores it too. @@ -131,6 +143,9 @@ func TestSniffFamily(t *testing.T) { if !s.Confident || s.Family != FamilyTabular { t.Fatalf("single csv + unrelated subdir should stay confident tabular, got %+v", s) } + if s.Hint != "" { + t.Fatalf("an unrelated subdir must not trigger a mis-cased hint, got %q", s.Hint) + } }) t.Run("images/ without labels.csv is not confident image", func(t *testing.T) { From 847ac1106770d219f44f403ab931c692795e46f1 Mon Sep 17 00:00:00 2001 From: Lukas Wuttke Date: Fri, 10 Jul 2026 11:17:03 +0200 Subject: [PATCH 2/2] fix(data ingest): mis-cased media sniff tracks the walk per-filesystem (#203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the code-review findings on the mis-cased-media guard. - Cross-platform false positive: the guard flagged a mis-cased "Images/" next to labels.csv as ambiguous and told the user to rename it — but on a case-insensitive filesystem (macOS APFS, Windows) the walk's own os.Lstat(/images) resolves that folder, so Discover already accepts the layout. The CLI preflight runs on the user's own machine, so a valid layout was reported broken with an unnecessary rename instruction. Now the sniff probes the literal lowercase marker path the walk keys on (markerResolves): if it resolves, treat the folder as the real marker (confident media, no hint); only when it doesn't resolve (case-sensitive FS, e.g. Linux) is it the genuine #203 footgun — stay ambiguous + hint. - Test coverage: add TestResolveFamily_SurfacesMiscasedHint, pinning that resolveFamily surfaces the advisory hint through the printer before asking the family plainly (the PR's headline behavior, previously exercised by no test). FS-aware, so on case-sensitive CI deleting the Warnf branch fails it. - Make the SniffFamily mis-cased test FS-aware to match the new behavior, and drop the dead `&& s.Confident` assertion (unreachable after the preceding not-confident guard). Co-Authored-By: Claude Opus 4.8 --- internal/cli/interactive_test.go | 51 +++++++++++++++++++ internal/push/preview.go | 86 +++++++++++++++++++++++--------- internal/push/preview_test.go | 55 +++++++++++++------- 3 files changed, 152 insertions(+), 40 deletions(-) diff --git a/internal/cli/interactive_test.go b/internal/cli/interactive_test.go index c15e35a0..6e36d42a 100644 --- a/internal/cli/interactive_test.go +++ b/internal/cli/interactive_test.go @@ -212,6 +212,57 @@ func TestRunInteractive_SniffIsHintNotLock(t *testing.T) { } } +// TestResolveFamily_SurfacesMiscasedHint pins the PR's headline behavior: an +// ambiguous sniff that carries an advisory hint (a mis-cased media folder next +// to labels.csv the walk can't see) has that hint surfaced through the printer +// before the family question — instead of silently ingesting the tree as a +// table (#203). The sniff tracks the walk, so behavior is FS-dependent; this +// asserts whichever branch applies on the machine it runs on. On a +// case-sensitive FS (Linux CI) the hint fires, so deleting resolveFamily's +// Warnf branch fails this test there. +func TestResolveFamily_SurfacesMiscasedHint(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "labels.csv"), + []byte("image_id,label\n001.jpg,cat\n"), 0o644); err != nil { + t.Fatalf("write labels.csv: %v", err) + } + if err := os.Mkdir(filepath.Join(dir, "Images"), 0o755); err != nil { + t.Fatalf("mkdir Images: %v", err) + } + + var buf bytes.Buffer + p := ui.New(&buf, ui.WithColor(false)) + f := &fakePrompter{answers: map[string]string{"What kind of data is this?": "image"}} + fam, err := resolveFamily(p, f, dir) + if err != nil { + t.Fatalf("resolveFamily: %v", err) + } + + if push.SniffFamily(dir).Hint != "" { + // Case-sensitive FS: the walk can't see Images/, so the sniff stays + // ambiguous with a rename hint. resolveFamily must print it, and still + // ask the family plainly (the hint is advisory, not a lock). + if !strings.Contains(buf.String(), "rename it and ingest again") { + t.Errorf("resolveFamily must surface the mis-cased rename hint; got:\n%s", buf.String()) + } + if !contains(f.asked, "What kind of data is this?") { + t.Errorf("hint is advisory — the family question must still be asked; asked=%v", f.asked) + } + } else { + // Case-insensitive FS: the walk resolves Images/, so the sniff is + // confident image — no false rename hint, no family question. + if fam != push.FamilyImage { + t.Errorf("family = %v, want image (walk resolves the mis-cased folder here)", fam) + } + if strings.Contains(buf.String(), "rename it and ingest again") { + t.Errorf("no false rename hint when the walk sees the folder; got:\n%s", buf.String()) + } + if contains(f.asked, "What kind of data is this?") { + t.Errorf("a confident sniff must not ask the family question; asked=%v", f.asked) + } + } +} + // TestRunInteractive_ExplicitTaskSkipsSniff: an explicit --task wins — no // sniff echo, no family question, no task picker. func TestRunInteractive_ExplicitTaskSkipsSniff(t *testing.T) { diff --git a/internal/push/preview.go b/internal/push/preview.go index 706b51f3..259925d0 100644 --- a/internal/push/preview.go +++ b/internal/push/preview.go @@ -39,12 +39,15 @@ type FamilySniff struct { // those (tabular), or a bare .csv file (tabular). It reads directory // entries only; it opens no files and validates nothing. // -// It never claims more than the matching Discover* would accept: the -// marker directories (images/, texts/, sequences/) and labels.csv are -// matched with the SAME literal, case-sensitive names the walk joins and -// Lstats — a mis-cased "Images/" is not the walk's marker, so it is not -// sniffed as confident image. Image / text are confident only when BOTH -// labels.csv AND the subdir are present, mirroring Discover / DiscoverText. +// It never claims more than the matching Discover* would accept: the marker +// directories (images/, texts/, sequences/) and labels.csv are probed with +// the SAME literal lowercase names the walk joins and Lstats. A mis-cased +// "Images/" is treated as the marker only when the walk's own +// os.Lstat(/images) resolves it — true on a case-insensitive filesystem +// (macOS, Windows), false on a case-sensitive one (Linux), so the sniff +// tracks the walk on the machine it runs on rather than guessing from the +// on-disk casing. Image / text are confident only when BOTH labels.csv AND +// the subdir are present, mirroring Discover / DiscoverText. // Tabular is confident on EXACTLY ONE CSV in a directory, mirroring // DiscoverTabular's findSingleCSV count rule — a directory with two or more // CSVs is a layout the tabular walk refuses, so the sniff must not @@ -100,12 +103,16 @@ func SniffFamily(path string) FamilySniff { // DiscoverTabular's EqualFold. var hasImages, hasTexts, hasSequences, hasLabels bool // miscasedMarker flags a subdir that matches a marker name only - // case-insensitively (e.g. "Images", "Texts") — a likely mis-cased - // media folder. The walk keys on the literal lowercase name, so such a - // dir is NOT a marker to it; but its lone labels.csv would otherwise - // fall through to the confident-tabular branch and get silently - // ingested as a table, images/texts dropped. When we see one, stay - // ambiguous and ask the family plainly. + // case-insensitively (e.g. "Images", "Texts") AND that the walk can't + // actually see. Whether the walk sees it is filesystem-dependent, so we + // don't guess from the on-disk casing — we probe the literal lowercase + // path the walk keys on (markerResolves, below). On a case-sensitive FS + // (Linux) a mis-cased dir is invisible to the walk, so its lone + // labels.csv would otherwise fall through to the confident-tabular + // branch and get silently ingested as a table, images/texts dropped — + // that's the footgun we flag. On a case-insensitive FS (macOS, Windows) + // the walk DOES resolve the mis-cased dir, so it's a real marker and we + // never set this. When set, stay ambiguous and ask the family plainly. miscasedMarker := false // miscasedName / miscasedCanonical hold the first mis-cased dir we see // and the lowercase marker it resembles, so the ambiguous return can @@ -123,13 +130,33 @@ func SniffFamily(path string) FamilySniff { case "sequences": hasSequences = true default: - // The exact lowercase markers are matched by the cases above, - // so anything that EqualFolds a marker here is case-insensitive - // but NOT exact — a likely mis-cased media folder. + // The exact lowercase markers are matched by the cases above, so + // anything that EqualFolds a marker here is case-insensitive but + // NOT exact — e.g. "Images". Whether the walk actually SEES it is + // filesystem-dependent, so mirror the walk rather than guess: + // Discover keys on the literal lowercase name via + // os.Lstat(filepath.Join(dir, "images")). On a case-insensitive + // FS (macOS APFS, Windows) that resolves the mis-cased dir, so + // the walk accepts it — treat it as the real marker, exactly as + // Discover will (no false rename hint). Only when the lowercase + // literal does NOT resolve (a case-sensitive FS, e.g. Linux) is + // this a genuine footgun the walk can't see: flag it ambiguous + // and name the likely rename. if m := markerFold(name); m != "" { - miscasedMarker = true - if miscasedName == "" { - miscasedName, miscasedCanonical = name, m + if markerResolves(abs, m) { + switch m { + case "images": + hasImages = true + case "texts": + hasTexts = true + case "sequences": + hasSequences = true + } + } else { + miscasedMarker = true + if miscasedName == "" { + miscasedName, miscasedCanonical = name, m + } } } } @@ -177,11 +204,12 @@ func SniffFamily(path string) FamilySniff { Echo: "Found a CSV table — this is tabular data."} case miscasedMarker: // A subdir matches a media marker case-insensitively but not exactly - // (e.g. "Images/"). The walk keys on the literal lowercase name, so it - // won't see this folder — an image/text layout here would otherwise - // look like a lone-CSV table and be silently ingested as one (#203). - // Stay ambiguous, but point at the likely rename so the user can fix - // the layout rather than just being asked a blind question. + // (e.g. "Images/"), AND markerResolves already confirmed the walk's + // literal lowercase path does NOT resolve on this filesystem — so the + // walk genuinely won't see this folder. An image/text layout here would + // otherwise look like a lone-CSV table and be silently ingested as one + // (#203). Stay ambiguous, but point at the likely rename so the user can + // fix the layout rather than just being asked a blind question. return FamilySniff{Hint: fmt.Sprintf( "Found a %q folder — image and text data use a lowercase folder like %q. "+ "If that's your data folder, rename it and ingest again.", @@ -191,6 +219,18 @@ func SniffFamily(path string) FamilySniff { } } +// markerResolves reports whether the walk's literal lowercase marker path +// (canonical is one of images / texts / sequences) resolves to a directory +// under dir — exactly the os.Lstat(filepath.Join(dir, canonical)) probe +// Discover / DiscoverText run. On a case-insensitive filesystem this resolves +// a mis-cased on-disk name (e.g. "Images"), so the sniff agrees with the walk +// that the folder IS the marker instead of emitting a false rename hint; on a +// case-sensitive filesystem it doesn't, exposing the real #203 footgun. +func markerResolves(dir, canonical string) bool { + fi, err := os.Lstat(filepath.Join(dir, canonical)) + return err == nil && fi.IsDir() +} + // markerFold returns the media-folder marker (images / texts / sequences) // that name matches ignoring case, or "" if none. Used only to detect a // mis-cased marker dir and name the correct lowercase form in the hint; the diff --git a/internal/push/preview_test.go b/internal/push/preview_test.go index 08802f15..d479ce6d 100644 --- a/internal/push/preview_test.go +++ b/internal/push/preview_test.go @@ -94,32 +94,53 @@ func TestSniffFamily(t *testing.T) { // The mis-cased-media footgun (#203): labels.csv next to a media folder // whose name matches a marker case-insensitively but not exactly. The - // walk keys on the literal lowercase name, so it won't see the folder; - // without the guard the lone labels.csv falls through to confident - // tabular and the media is silently ingested away as a table. Each - // mis-cased marker (Images/, Texts/, Sequences/) must stay ambiguous — - // NOT confident image AND NOT confident tabular — and carry a hint that - // names the likely lowercase rename so the flow can guide the fix. - for _, tc := range []struct{ folder, canonical string }{ - {"Images", "images"}, - {"Texts", "texts"}, - {"Sequences", "sequences"}, + // sniff mirrors the walk, which keys on the literal lowercase name via + // os.Lstat — so behavior is filesystem-dependent, and each subtest asserts + // the branch that actually applies on the FS it runs on: + // - case-SENSITIVE FS (Linux CI): the walk can't see the folder, so the + // lone labels.csv would otherwise fall through to confident tabular and + // the media be silently ingested away. The sniff must stay ambiguous + // (NOT confident — that covers the confident-tabular footgun) and carry + // a rename hint naming both the folder and its lowercase form. + // - case-INSENSITIVE FS (macOS APFS, Windows): the walk resolves the + // mis-cased folder under its lowercase name, so the layout is valid. + // The sniff must AGREE — confident media, no false rename hint telling + // the user to fix a layout that already works (#203 cross-platform). + for _, tc := range []struct { + folder, canonical string + wantFamily Family + }{ + {"Images", "images", FamilyImage}, + {"Texts", "texts", FamilyText}, + {"Sequences", "sequences", FamilyText}, } { tc := tc - t.Run("mis-cased "+tc.folder+"/ + labels.csv is ambiguous with a rename hint", func(t *testing.T) { + t.Run("mis-cased "+tc.folder+"/ + labels.csv tracks the walk", func(t *testing.T) { dir := t.TempDir() writePrev(t, filepath.Join(dir, "labels.csv"), "id,label\n1,c\n") if err := os.Mkdir(filepath.Join(dir, tc.folder), 0o755); err != nil { t.Fatal(err) } + // The walk's own probe: does the literal lowercase marker resolve? + fi, err := os.Lstat(filepath.Join(dir, tc.canonical)) + walkSeesIt := err == nil && fi.IsDir() + s := SniffFamily(dir) - if s.Confident { - t.Fatalf("mis-cased %s/ + labels.csv must be ambiguous, got %+v", tc.folder, s) + if walkSeesIt { + // Case-insensitive FS: the folder IS the marker to the walk. + if !s.Confident || s.Family != tc.wantFamily { + t.Fatalf("case-insensitive FS: mis-cased %s/ should sniff confident %v, got %+v", tc.folder, tc.wantFamily, s) + } + if s.Hint != "" { + t.Fatalf("no false rename hint when the walk resolves %s/, got %q", tc.folder, s.Hint) + } + return } - // Regression: the real footgun is confident TABULAR, not just - // not-image. Pin that the family is not confidently claimed at all. - if s.Family == FamilyTabular && s.Confident { - t.Fatalf("mis-cased %s/ must NOT sniff as confident tabular, got %+v", tc.folder, s) + // Case-sensitive FS: the real #203 footgun. Not confident at all — + // that single check covers the confident-tabular masquerade — plus a + // rename hint that names both the folder and its lowercase form. + if s.Confident { + t.Fatalf("case-sensitive FS: mis-cased %s/ + labels.csv must be ambiguous, got %+v", tc.folder, s) } if s.Hint == "" { t.Fatalf("mis-cased %s/ should carry a rename hint, got %+v", tc.folder, s)