// SPDX-License-Identifier: GPL-3.0-or-later package main import ( "context" "errors" "fmt" "io" "strings" "testing" "unicode/utf8" "krino/internal/engine" "krino/internal/plan" "krino/internal/scan" ) func chains(rels ...string) []plan.Chain { out := make([]plan.Chain, len(rels)) for i, r := range rels { out[i] = plan.Chain{File: scan.File{Rel: r}, Steps: []plan.Step{{Kind: plan.Move, Dst: "/w/" + r}}} } return out } func TestChoosePerFile(t *testing.T) { // c enters per-file mode, then y n y for three files. approved, _, action, err := reviewChains(strings.NewReader("cyny"), new(strings.Builder), chains("a", "b", "c"), "", palette{}) if err != nil { t.Fatal(err) } if action != 'c' { t.Errorf("action = %q", action) } if !approved["a"] || approved["b"] || !approved["c"] { t.Errorf("approved = %v; want a and c only", approved) } } func TestApplyAllAndSkip(t *testing.T) { approved, _, action, _ := reviewChains(strings.NewReader("a"), new(strings.Builder), chains("a", "b"), "", palette{}) if action != 'a' || len(approved) != 2 { t.Errorf("[a] = %q %v; want every file approved", action, approved) } approved, _, action, _ = reviewChains(strings.NewReader("s"), new(strings.Builder), chains("a", "b"), "", palette{}) if action != 's' || len(approved) != 0 { t.Errorf("[s] = %q %v; want nothing approved", action, approved) } } func TestPerFileWriteStopsAsking(t *testing.T) { // c, y for the first, n for the second, then w: apply what was chosen so // far and quit. Only the files reached are decided; c was never reviewed. decided, _, action, _ := reviewChains(strings.NewReader("cynw"), new(strings.Builder), chains("a", "b", "c"), "", palette{}) if action != 'w' { t.Errorf("action = %q, want 'w'", action) } if got, ok := decided["b"]; !ok || got { t.Errorf("b should be decided as declined: %v", decided) } if _, ok := decided["c"]; ok || !decided["a"] { t.Errorf("decided = %v; want a approved, b declined, c not reviewed", decided) } } // TestPerFileQuitAppliesNothing: spec §8.3's [q] on the per-file prompt is // "quit, apply nothing" - stronger than [w], which keeps what was already // chosen. It folds into the same top-level 'q' the caller already handles // for the directory-level menu (spec §8.2's [q]), and discards even a file // already marked yes. func TestPerFileQuitAppliesNothing(t *testing.T) { approved, _, action, err := reviewChains(strings.NewReader("cyq"), new(strings.Builder), chains("a", "b"), "", palette{}) if err != nil { t.Fatal(err) } if action != 'q' { t.Errorf("action = %q, want 'q'", action) } if len(approved) != 0 { t.Errorf("approved = %v; want nothing, even though a was marked yes first", approved) } } // TestPerFileYesToAllRemaining: spec §8.3's [a] mid-review approves the // current file and every remaining one without asking again. func TestPerFileYesToAllRemaining(t *testing.T) { approved, _, action, err := reviewChains(strings.NewReader("ca"), new(strings.Builder), chains("a", "b", "c"), "", palette{}) if err != nil { t.Fatal(err) } if action != 'c' || len(approved) != 3 { t.Errorf("approved = %v action = %q; want all three approved", approved, action) } } // TestInvalidKeyReprompts: an unrecognised key at either the top-level menu // or the per-file prompt does not abort the review - it is reported and the // same prompt is read again. func TestInvalidKeyReprompts(t *testing.T) { out := new(strings.Builder) approved, _, action, err := reviewChains(strings.NewReader("zs"), out, chains("a"), "", palette{}) if err != nil { t.Fatal(err) } if action != 's' || len(approved) != 0 { t.Errorf("approved = %v action = %q; want [s] after the bad key", approved, action) } if !strings.Contains(out.String(), "z") { t.Errorf("no mention of the rejected key:\n%s", out) } } // TestPerFileDestinationIsRootRelative is review finding 1 (fix round // 2026-09-12): reviewPerFile must render a destination the same way the // directory-level table does (render.go's destText) - root-relative for a // destination inside root, ~-abbreviated for one outside it - not always // abbreviated because root was never passed through to actionCell at all. // Both halves are pinned: getting only the "inside" half right would still // let an outside-root destination silently regress to some other form. func TestPerFileDestinationIsRootRelative(t *testing.T) { t.Setenv("HOME", "/home/x") root := "/home/x/dl" cs := []plan.Chain{ {File: scan.File{Rel: "inside.txt"}, Steps: []plan.Step{{Kind: plan.Move, Dst: root + "/Work/Acme/inside.txt"}}}, {File: scan.File{Rel: "outside.txt"}, Steps: []plan.Step{{Kind: plan.Move, Dst: "/home/x/backup/outside.txt"}}}, } out := new(strings.Builder) if _, _, _, err := reviewChains(strings.NewReader("cyy"), out, cs, root, palette{}); err != nil { t.Fatal(err) } text := out.String() if !strings.Contains(text, "move → Work/Acme/") { t.Errorf("destination inside root should be root-relative, not ~-abbreviated:\n%s", text) } if !strings.Contains(text, "move → ~/backup/") { t.Errorf("destination outside root should be ~-abbreviated:\n%s", text) } } // TestPerFileShowsTheWholeBlock: choosing per file shows each step, its // rule and its reason: the same block the plan shows, not the steps alone. func TestPerFileShowsTheWholeBlock(t *testing.T) { cs := []plan.Chain{{File: scan.File{Rel: "a.pdf"}, Steps: []plan.Step{ {Kind: plan.Move, Rule: "acme", Dst: "/w/a.pdf", Reason: `content "acme ltd"`}, }}} out := new(strings.Builder) if _, _, _, err := reviewChains(strings.NewReader("cy"), out, cs, "", palette{}); err != nil { t.Fatal(err) } want := "\n[1/1] a.pdf\n move → /w/\n rule acme\n because content \"acme ltd\"\n" if !strings.Contains(out.String(), want) { t.Errorf("per-file prompt:\n%s\nwant substring:\n%s", out, want) } } // TestPerFileWrapsToTheTerminal: on a narrow terminal the per-file block // and its key prompt both fit, and the prompt's pieces read back as the // whole prompt. func TestPerFileWrapsToTheTerminal(t *testing.T) { old := widthPolicy t.Cleanup(func() { widthPolicy = old }) widthPolicy = func(io.Writer) int { return 60 } cs := []plan.Chain{{File: scan.File{Rel: "a.pdf"}, Steps: []plan.Step{ {Kind: plan.Move, Rule: "acme", Dst: "/w/some/deeply/nested/destination/directory/for/invoices/a.pdf", Reason: `content "acme ltd"`}, }}} out := new(strings.Builder) if _, _, _, err := reviewChains(strings.NewReader("cy"), out, cs, "", palette{}); err != nil { t.Fatal(err) } var prompt string for _, l := range strings.Split(out.String(), "\n") { if n := utf8.RuneCountInString(l); n > 60 { t.Errorf("line of %d runes exceeds width 60: %q", n, l) } if strings.HasPrefix(l, " [y] yes") || (prompt != "" && strings.HasPrefix(l, " ") && !strings.HasPrefix(l, " ") && !strings.HasPrefix(l, " →")) { prompt += strings.TrimPrefix(l, " ") } } if prompt != perFileKeys { t.Errorf("wrapped prompt reads %q, want %q\n%s", prompt, perFileKeys, out) } } // TestPerFileTrashAndDelete: [t] and [d] (confirmed with y) replace what the // rules planned for that file with one step, and approve it. func TestPerFileTrashAndDelete(t *testing.T) { out := new(strings.Builder) approved, replaced, action, err := reviewChains(strings.NewReader("ctdyn"), out, chains("a", "b", "c"), "", palette{}) if err != nil { t.Fatal(err) } if action != 'c' || !approved["a"] || !approved["b"] || approved["c"] { t.Errorf("approved = %v action = %q; want a and b", approved, action) } if len(replaced) != 2 || replaced["a"] != plan.Trash || replaced["b"] != plan.DeletePermanent { t.Errorf("replaced = %v; want a trash, b deleted permanently", replaced) } if !strings.Contains(out.String(), "delete b permanently? [y/N]") { t.Errorf("no confirmation asked:\n%s", out) } } // TestPerFileDeleteNeedsConfirmation: any key but y after [d] deletes // nothing and asks about the same file again. func TestPerFileDeleteNeedsConfirmation(t *testing.T) { out := new(strings.Builder) approved, replaced, _, err := reviewChains(strings.NewReader("cdny"), out, chains("a"), "", palette{}) if err != nil { t.Fatal(err) } if len(replaced) != 0 || !approved["a"] { t.Errorf("approved = %v replaced = %v; want a approved as planned, nothing replaced", approved, replaced) } if strings.Count(out.String(), perFileKeys) != 2 { t.Errorf("the prompt should be shown again after a cancelled delete:\n%s", out) } } // TestPerFileQuitDiscardsReplacements: [q] after [t] applies nothing, the // trash included. func TestPerFileQuitDiscardsReplacements(t *testing.T) { approved, replaced, action, _ := reviewChains(strings.NewReader("ctq"), new(strings.Builder), chains("a", "b"), "", palette{}) if action != 'q' || len(approved) != 0 || len(replaced) != 0 { t.Errorf("approved = %v replaced = %v action = %q; want nothing", approved, replaced, action) } } // TestReplaceChains: a replaced file's chain becomes one step of the chosen // kind on the file itself, under the rule name (review); other chains keep // their steps. func TestReplaceChains(t *testing.T) { cs := chains("a", "b") cs[0].File.Path = "/dl/a" cs[0].Steps = append(cs[0].Steps, plan.Step{Kind: plan.Copy, Dst: "/w/copy"}) got := replaceChains(cs, map[string]plan.Kind{"a": plan.DeletePermanent}) want := plan.Step{Kind: plan.DeletePermanent, Rule: "(review)", Src: "/dl/a", Reason: "chosen in review"} if len(got[0].Steps) != 1 || got[0].Steps[0] != want { t.Errorf("a's steps = %+v; want only %+v", got[0].Steps, want) } if len(got[1].Steps) != 1 || got[1].Steps[0].Kind != plan.Move { t.Errorf("b's steps changed: %+v", got[1].Steps) } if len(cs[0].Steps) != 2 { t.Errorf("replaceChains changed its input: %+v", cs[0].Steps) } } // TestPerFileEchoesChoiceInRed: every choice is confirmed on its own line, // red when colour is on. func TestPerFileEchoesChoiceInRed(t *testing.T) { out := new(strings.Builder) if _, _, _, err := reviewChains(strings.NewReader("cyntdya"), out, chains("a", "b", "c", "d", "e", "f"), "", palette{on: true}); err != nil { t.Fatal(err) } for _, want := range []string{"yes", "no", "trash", "DELETE permanently", "yes, and all remaining"} { if line := " \x1b[31m→ " + want + "\x1b[0m\n"; !strings.Contains(out.String(), line) { t.Errorf("no red %q line in:\n%q", want, out) } } plain := new(strings.Builder) reviewChains(strings.NewReader("cn"), plain, chains("a"), "", palette{}) if !strings.Contains(plain.String(), "\n → no\n") { t.Errorf("without colour the choice should still be shown:\n%s", plain) } } // TestEnterIsIgnored: Enter, at either prompt, is not a key krino // complains about. func TestEnterIsIgnored(t *testing.T) { out := new(strings.Builder) decided, _, action, err := reviewChains(strings.NewReader("\rc\ry\n"), out, chains("a"), "", palette{}) if err != nil { t.Fatal(err) } if action != 'c' || !decided["a"] { t.Errorf("decided = %v action = %q", decided, action) } if strings.Contains(out.String(), "is not") { t.Errorf("Enter was reported as a bad key:\n%s", out) } } // TestReviewedChains: after [w], only the files decided in review go to // Apply; the rest are neither applied nor logged. func TestReviewedChains(t *testing.T) { got := reviewedChains(chains("a", "b", "c"), map[string]bool{"a": true, "b": false}) if len(got) != 2 || got[0].File.Rel != "a" || got[1].File.Rel != "b" { t.Errorf("reviewedChains = %+v; want a and b", got) } } // TestNotReviewedOutcome: the outcome line counts files [w] left unreviewed. func TestNotReviewedOutcome(t *testing.T) { if got := withNotReviewed("2 applied · 0 failed · 1 declined", 139); got != "2 applied · 0 failed · 1 declined · 139 not reviewed" { t.Errorf("got %q", got) } if got := withNotReviewed("2 applied · 0 failed · 1 declined", 0); got != "2 applied · 0 failed · 1 declined" { t.Errorf("got %q", got) } } // TestStopAfterApply: [w] stops krino after its directory whether or not the // apply succeeded (review cli F3) - a log that cannot be written must not // lead on to planning and prompting the next directory - and an interrupt // always stops it. func TestStopAfterApply(t *testing.T) { logErr := errors.New("write krino.log: no space left on device") cases := []struct { action rune err error want bool }{ {'a', nil, false}, {'c', nil, false}, {'w', nil, true}, {'a', logErr, false}, {'c', logErr, false}, {'w', logErr, true}, {'a', context.Canceled, true}, {'c', fmt.Errorf("apply: %w", context.Canceled), true}, {'a', context.DeadlineExceeded, true}, } for _, c := range cases { if got := stopAfterApply(c.action, c.err); got != c.want { t.Errorf("stopAfterApply(%q, %v) = %v, want %v", c.action, c.err, got, c.want) } } } // TestReviewHeadingsCannotFakeAStepLine: a long name in the per-file // heading of review and of undo wraps with its continuation past the column // step labels use, so the name cannot pass for a step (plan 11 review L4). func TestReviewHeadingsCannotFakeAStepLine(t *testing.T) { old := widthPolicy t.Cleanup(func() { widthPolicy = old }) widthPolicy = func(io.Writer) int { return 60 } name := strings.Repeat("x", 50) + " DELETE permanently" out := new(strings.Builder) cs := []plan.Chain{{File: scan.File{Rel: name}, Steps: []plan.Step{{Kind: plan.Move, Rule: "r", Dst: "/w/Out/a.pdf"}}}} if _, _, _, err := reviewChains(strings.NewReader("cy"), out, cs, "/w", palette{}); err != nil { t.Fatal(err) } for _, l := range strings.Split(out.String(), "\n") { if strings.HasPrefix(strings.TrimLeft(l, " "), "DELETE") { t.Errorf("review: a line reads as a step: %q\n%s", l, out) } if n := cols(l); n > 60 { t.Errorf("review: a line of %d columns, over 60, which the terminal wraps: %q", n, l) } } out.Reset() files := []engine.UndoFile{{Dir: "dl", File: name, Steps: []engine.UndoStep{{Action: "undo-move", Src: "/t/a", Dst: "/s/a"}}}} if _, _, err := reviewUndoFiles(strings.NewReader("cy"), out, files, palette{}); err != nil { t.Fatal(err) } for _, l := range strings.Split(out.String(), "\n") { if strings.HasPrefix(strings.TrimLeft(l, " "), "DELETE") { t.Errorf("undo: a line reads as a step: %q\n%s", l, out) } if n := cols(l); n > 60 { t.Errorf("undo: a line of %d columns, over 60, which the terminal wraps: %q", n, l) } } }