diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-14 15:16:55 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-14 15:16:55 +0200 |
| commit | 0b5d0eb92c5be2f0ddb2fa73990f31e5654e57fe (patch) | |
| tree | 358e331945b8206ed4a72703e3aedfe8ea7cdcdb /cmd | |
| parent | 1d3f2d1e4c59867024470d3444e12698b7ebb22e (diff) | |
| download | krino-0b5d0eb92c5be2f0ddb2fa73990f31e5654e57fe.tar.gz krino-0b5d0eb92c5be2f0ddb2fa73990f31e5654e57fe.zip | |
krino: 0.0.5 — keyword cache, t and d in reviewv0.0.5
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/krino/cache_test.go | 47 | ||||
| -rw-r--r-- | cmd/krino/check.go | 1 | ||||
| -rw-r--r-- | cmd/krino/common.go | 6 | ||||
| -rw-r--r-- | cmd/krino/explain.go | 1 | ||||
| -rw-r--r-- | cmd/krino/history_test.go | 16 | ||||
| -rw-r--r-- | cmd/krino/review.go | 99 | ||||
| -rw-r--r-- | cmd/krino/review_test.go | 90 | ||||
| -rw-r--r-- | cmd/krino/sort.go | 7 | ||||
| -rw-r--r-- | cmd/krino/undo.go | 12 |
9 files changed, 234 insertions, 45 deletions
diff --git a/cmd/krino/cache_test.go b/cmd/krino/cache_test.go new file mode 100644 index 0000000..65526b3 --- /dev/null +++ b/cmd/krino/cache_test.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestRunWritesKeywordCache: a dry run over a directory with a content rule +// leaves a private cache under ~/.cache/krino, and check says where it is. +func TestRunWritesKeywordCache(t *testing.T) { + h := home(t) + dl := filepath.Join(h, "dl") + os.MkdirAll(dl, 0o755) + p := filepath.Join(dl, "a.txt") + os.WriteFile(p, []byte("invoice from acme ltd"), 0o644) + old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + os.Chtimes(p, old, old) + if code, _, errOut := runCLI(t, "init"); code != 0 { + t.Fatal(errOut) + } + if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { + t.Fatal(errOut) + } + rules := "(path \"~/dl\")\n(rule \"acme\" (when (content \"acme ltd\")) (move \"Acme\"))\n" + os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", "dl.conf"), []byte(rules), 0o644) + + for i := 0; i < 2; i++ { + if code, out, errOut := runCLI(t, "-n"); code != 0 || !strings.Contains(out, "a.txt") { + t.Fatalf("run %d: exit %d\n%s\n%s", i, code, out, errOut) + } + } + fi, err := os.Stat(filepath.Join(h, ".cache", "krino", "dl.cache")) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o600 { + t.Errorf("cache mode %o, want 600", fi.Mode().Perm()) + } + if _, out, _ := runCLI(t, "check"); !strings.Contains(out, "cache: ~/.cache/krino\n") { + t.Errorf("check does not show the cache directory:\n%s", out) + } +} diff --git a/cmd/krino/check.go b/cmd/krino/check.go index dfe3a1a..4463c73 100644 --- a/cmd/krino/check.go +++ b/cmd/krino/check.go @@ -28,6 +28,7 @@ func cmdCheck(g *globals, args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stdout, "config: %s\n", xdg.Abbrev(r.MainFile)) fmt.Fprintf(stdout, "log: %s\n", xdg.Abbrev(r.LogFile)) + fmt.Fprintf(stdout, "cache: %s\n", xdg.Abbrev(cacheDir())) if len(r.Dirs) == 0 { fmt.Fprintln(stdout, "no directories included; add one with: krino new NAME PATH") } diff --git a/cmd/krino/common.go b/cmd/krino/common.go index 4552441..11e73c5 100644 --- a/cmd/krino/common.go +++ b/cmd/krino/common.go @@ -5,6 +5,7 @@ package main import ( "fmt" "io" + "path/filepath" "strings" "time" @@ -30,6 +31,11 @@ func minAgeOverride(g *globals) (d time.Duration, set bool, err error) { return d, true, nil } +// cacheDir is where every directory's keyword cache lives (spec §6.1). +func cacheDir() string { + return filepath.Join(xdg.CacheHome(), "krino") +} + // applyMinAge sets every loaded directory's min-age to d, for this run only. func applyMinAge(e *engine.Engine, d time.Duration) { for _, dir := range e.Dirs { diff --git a/cmd/krino/explain.go b/cmd/krino/explain.go index 6f8fa26..8fade24 100644 --- a/cmd/krino/explain.go +++ b/cmd/krino/explain.go @@ -39,6 +39,7 @@ func cmdExplain(g *globals, args []string, stdout, stderr io.Writer) int { if setMinAge { applyMinAge(e, minAge) } + e.CacheDir = cacheDir() x, err := e.Explain(context.Background(), xdg.Expand(fs.Arg(0))) if err != nil { diff --git a/cmd/krino/history_test.go b/cmd/krino/history_test.go index f76ac66..5d86b6f 100644 --- a/cmd/krino/history_test.go +++ b/cmd/krino/history_test.go @@ -182,6 +182,22 @@ func TestReviewUndoChoosePerFile(t *testing.T) { } } +// TestReviewUndoWriteStopsAsking: [w] applies what was chosen so far; undo +// offers no [t] or [d], so those are rejected keys there. +func TestReviewUndoWriteStopsAsking(t *testing.T) { + out := new(strings.Builder) + approved, action, err := reviewUndoFiles(strings.NewReader("ctdyw"), out, undoFiles("a", "b", "c"), palette{}) + if err != nil { + t.Fatal(err) + } + if action != 'c' || !approved[0] || approved[1] || approved[2] { + t.Errorf("approved = %v action = %q; want only index 0", approved, action) + } + if !strings.Contains(out.String(), "'t' is not y, n, a, w or q") || strings.Contains(out.String(), "[t]") { + t.Errorf("undo review should reject t and not offer it:\n%s", out) + } +} + // TestReviewUndoRefusedFileNotPrompted is spec §10: a file PlanUndo already // refused is shown (with its reason - covered end to end by // TestUndoRefusesChangedDestination-style flows through cmdUndo) but never diff --git a/cmd/krino/review.go b/cmd/krino/review.go index 66e17b9..29b3915 100644 --- a/cmd/krino/review.go +++ b/cmd/krino/review.go @@ -21,7 +21,7 @@ import ( // directory-level table does (root-relative inside root, ~-abbreviated // outside it) instead of always falling back to the abbreviated form. p // styles the prompts and the per-file steps (spec §8.2). -func reviewDir(out io.Writer, chains []plan.Chain, root string, p palette) (map[string]bool, rune, error) { +func reviewDir(out io.Writer, chains []plan.Chain, root string, p palette) (map[string]bool, map[string]plan.Kind, rune, error) { return reviewChains(keyReader{stdin}, out, chains, root, p) } @@ -47,17 +47,18 @@ func (k keyReader) Read(p []byte) (int, error) { // // menu, and, for [c], the per-file // -// [y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing +// [y] yes [n] no [a] yes to this and all remaining [t] trash [d] delete permanently [w] write, apply chosen so far [q] quit, apply nothing // // prompt. action is always one of 'a', 'c', 's' or 'q': a [c] session's own // [q] ("quit, apply nothing") folds into the same 'q' the caller already // handles for the top-level menu, and approved is emptied to match - even a // file already marked yes in that session is discarded, per spec §8.3's -// wording ("apply nothing"), unlike [d] ("apply chosen so far"), which -// keeps it. root is the directory being reviewed - passed only to +// wording ("apply nothing"), unlike [w] ("apply chosen so far"), which +// keeps it. replaced holds the files [t] or [d] chose to trash or delete +// instead of what the rules planned; replaceChains applies it. root is the directory being reviewed - passed only to // reviewPerFile's destination rendering (review finding 1, fix round // 2026-09-12); nothing here uses it directly. -func reviewChains(in io.Reader, out io.Writer, chains []plan.Chain, root string, p palette) (map[string]bool, rune, error) { +func reviewChains(in io.Reader, out io.Writer, chains []plan.Chain, root string, p palette) (map[string]bool, map[string]plan.Kind, rune, error) { fmt.Fprintln(out) for _, l := range wrapped("", "[a] apply all [c] choose per file [s] skip this directory [q] quit", 0, widthPolicy(out), p.keys) { fmt.Fprintln(out, l) @@ -65,24 +66,24 @@ func reviewChains(in io.Reader, out io.Writer, chains []plan.Chain, root string, for { key, err := readKey(in) if err != nil { - return nil, 0, err + return nil, nil, 0, err } switch key { case 'a': - return approveAll(chains), 'a', nil + return approveAll(chains), nil, 'a', nil case 's': - return map[string]bool{}, 's', nil + return map[string]bool{}, nil, 's', nil case 'q': - return map[string]bool{}, 'q', nil + return map[string]bool{}, nil, 'q', nil case 'c': - approved, quit, err := reviewPerFile(in, out, chains, root, p) + approved, replaced, quit, err := reviewPerFile(in, out, chains, root, p) if err != nil { - return nil, 0, err + return nil, nil, 0, err } if quit { - return map[string]bool{}, 'q', nil + return map[string]bool{}, nil, 'q', nil } - return approved, 'c', nil + return approved, replaced, 'c', nil default: fmt.Fprintf(out, "%q is not a, c, s or q\n", key) } @@ -94,14 +95,18 @@ func reviewChains(in io.Reader, out io.Writer, chains []plan.Chain, root string, // Each file shows the same block body the plan shows (stepLines): every // step, its rule and its reason, wrapped to the terminal. [y]/[n] decide // just that file; [a] approves it and every remaining file without asking -// again; [d] stops asking and applies whatever was already chosen, +// again; [t] and [d] approve it with its chain replaced by one trash or +// permanent delete step, [d] only after a y to its own confirmation (any +// other key asks about the same file again); [w] stops asking and applies +// whatever was already chosen, // declining the rest; [q] aborts the review entirely, discarding even files // already marked yes - reported back to reviewChains via quit=true. root is // passed down so a destination inside root renders root-relative and one // outside it renders ~-abbreviated, exactly as in the plan (review finding // 1, fix round 2026-09-12). -func reviewPerFile(in io.Reader, out io.Writer, chains []plan.Chain, root string, p palette) (approved map[string]bool, quit bool, err error) { +func reviewPerFile(in io.Reader, out io.Writer, chains []plan.Chain, root string, p palette) (approved map[string]bool, replaced map[string]plan.Kind, quit bool, err error) { approved = map[string]bool{} + replaced = map[string]plan.Kind{} yesRest := false for i, c := range chains { if yesRest { @@ -113,14 +118,17 @@ func reviewPerFile(in io.Reader, out io.Writer, chains []plan.Chain, root string for _, l := range stepLines(c, 7, root, p, widthPolicy(out)) { fmt.Fprintln(out, l) } - for _, l := range wrapped(" ", perFileKeys, 2, widthPolicy(out), p.keys) { - fmt.Fprintln(out, l) - } - + showKeys := true for { + if showKeys { + for _, l := range wrapped(" ", perFileKeys, 2, widthPolicy(out), p.keys) { + fmt.Fprintln(out, l) + } + showKeys = false + } key, kerr := readKey(in) if kerr != nil { - return nil, false, kerr + return nil, nil, false, kerr } switch key { case 'y': @@ -130,23 +138,60 @@ func reviewPerFile(in io.Reader, out io.Writer, chains []plan.Chain, root string case 'a': approved[c.File.Rel] = true yesRest = true + case 't': + approved[c.File.Rel] = true + replaced[c.File.Rel] = plan.Trash case 'd': - return approved, false, nil + fmt.Fprintf(out, " delete %s permanently? [y/N] ", c.File.Rel) + confirm, kerr := readKey(in) + if kerr != nil { + return nil, nil, false, kerr + } + fmt.Fprintln(out) + if confirm != 'y' { + fmt.Fprintln(out, " not deleted") + showKeys = true + continue + } + approved[c.File.Rel] = true + replaced[c.File.Rel] = plan.DeletePermanent + case 'w': + return approved, replaced, false, nil case 'q': - return nil, true, nil + return nil, nil, true, nil default: - fmt.Fprintf(out, "%q is not y, n, a, d or q\n", key) + fmt.Fprintf(out, "%q is not y, n, a, t, d, w or q\n", key) continue } break } } - return approved, false, nil + return approved, replaced, false, nil } -// perFileKeys is the per-file prompt of spec §8.3, shared by review and -// undo, and wrapped to the terminal like every other long line. -const perFileKeys = "[y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing" +// perFileKeys is the per-file prompt of spec §8.3, wrapped to the terminal +// like every other long line. Undo's has no [t] or [d] (undoPerFileKeys). +const perFileKeys = "[y] yes [n] no [a] yes to this and all remaining [t] trash [d] delete permanently [w] write, apply chosen so far [q] quit, apply nothing" + +// reviewRule is the rule name a step chosen in review is planned and logged +// under, in parentheses so no configured rule can be mistaken for it. +const reviewRule = "(review)" + +// replaceChains returns chains with the chain of every file in replaced +// swapped for one step of the chosen kind on the file itself, under +// reviewRule: [t] and [d] set aside what the rules planned. chains itself +// is not modified. +func replaceChains(chains []plan.Chain, replaced map[string]plan.Kind) []plan.Chain { + out := make([]plan.Chain, len(chains)) + for i, c := range chains { + if k, ok := replaced[c.File.Rel]; ok { + c.Steps = []plan.Step{{Kind: k, Rule: reviewRule, Src: c.File.Path, Reason: "chosen in review"}} + c.Warnings = nil + } + out[i] = c + } + return out +} // readKey reads the single byte reviewChains treats as one keypress. Over // the real terminal that byte already came from tui.ReadKey (via diff --git a/cmd/krino/review_test.go b/cmd/krino/review_test.go index a387022..19a1d01 100644 --- a/cmd/krino/review_test.go +++ b/cmd/krino/review_test.go @@ -22,7 +22,7 @@ func chains(rels ...string) []plan.Chain { 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{}) + approved, _, action, err := reviewChains(strings.NewReader("cyny"), new(strings.Builder), chains("a", "b", "c"), "", palette{}) if err != nil { t.Fatal(err) } @@ -35,31 +35,31 @@ func TestChoosePerFile(t *testing.T) { } func TestApplyAllAndSkip(t *testing.T) { - approved, action, _ := reviewChains(strings.NewReader("a"), new(strings.Builder), chains("a", "b"), "", palette{}) + 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{}) + 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 TestPerFileDoneStopsAsking(t *testing.T) { - // c, y for the first, then d: apply what was chosen so far. - approved, _, _ := reviewChains(strings.NewReader("cyd"), new(strings.Builder), chains("a", "b", "c"), "", palette{}) +func TestPerFileWriteStopsAsking(t *testing.T) { + // c, y for the first, then w: apply what was chosen so far. + approved, _, _, _ := reviewChains(strings.NewReader("cyw"), new(strings.Builder), chains("a", "b", "c"), "", palette{}) if !approved["a"] || approved["b"] || approved["c"] { t.Errorf("approved = %v; want only a", approved) } } // TestPerFileQuitAppliesNothing: spec §8.3's [q] on the per-file prompt is -// "quit, apply nothing" - stronger than [d], which keeps what was already +// "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{}) + approved, _, action, err := reviewChains(strings.NewReader("cyq"), new(strings.Builder), chains("a", "b"), "", palette{}) if err != nil { t.Fatal(err) } @@ -74,7 +74,7 @@ func TestPerFileQuitAppliesNothing(t *testing.T) { // 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{}) + approved, _, action, err := reviewChains(strings.NewReader("ca"), new(strings.Builder), chains("a", "b", "c"), "", palette{}) if err != nil { t.Fatal(err) } @@ -88,7 +88,7 @@ func TestPerFileYesToAllRemaining(t *testing.T) { // 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{}) + approved, _, action, err := reviewChains(strings.NewReader("zs"), out, chains("a"), "", palette{}) if err != nil { t.Fatal(err) } @@ -115,7 +115,7 @@ func TestPerFileDestinationIsRootRelative(t *testing.T) { {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 { + if _, _, _, err := reviewChains(strings.NewReader("cyy"), out, cs, root, palette{}); err != nil { t.Fatal(err) } text := out.String() @@ -134,7 +134,7 @@ func TestPerFileShowsTheWholeBlock(t *testing.T) { {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 { + 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" @@ -154,7 +154,7 @@ func TestPerFileWrapsToTheTerminal(t *testing.T) { {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 { + if _, _, _, err := reviewChains(strings.NewReader("cy"), out, cs, "", palette{}); err != nil { t.Fatal(err) } var prompt string @@ -170,3 +170,67 @@ func TestPerFileWrapsToTheTerminal(t *testing.T) { 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) + } +} diff --git a/cmd/krino/sort.go b/cmd/krino/sort.go index 9e714ba..3e5d951 100644 --- a/cmd/krino/sort.go +++ b/cmd/krino/sort.go @@ -69,6 +69,7 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int { if setMinAge { applyMinAge(e, minAge) } + e.CacheDir = cacheDir() p := palette{on: colourOn(g, stdout)} // Spec §8.4: with neither -y nor -n, krino asks; asking a non-terminal @@ -213,12 +214,13 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int { } var approved map[string]bool + var replaced map[string]plan.Kind var action rune if g.yes { approved, action = approveAll(actionable), 'a' } else { var rerr error - approved, action, rerr = reviewDir(stdout, actionable, d.Root, p) + approved, replaced, action, rerr = reviewDir(stdout, actionable, d.Root, p) if rerr != nil { fmt.Fprintf(stderr, "krino: %s: %v\n", d.Name, rerr) exit = 1 @@ -242,6 +244,9 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int { return true } + // [t] and [d] in review: the file gets the one step chosen + // there instead of the chain its rules planned. + dp.Chains = replaceChains(dp.Chains, replaced) res, aerr := e.Apply(ctx, dp, approved, j, run) if aerr != nil { if errors.Is(aerr, context.Canceled) || errors.Is(aerr, context.DeadlineExceeded) { diff --git a/cmd/krino/undo.go b/cmd/krino/undo.go index d31a5b3..036e3c0 100644 --- a/cmd/krino/undo.go +++ b/cmd/krino/undo.go @@ -325,7 +325,7 @@ func reviewUndoDir(out io.Writer, files []engine.UndoFile, p palette) (map[int]b // // menu, and, for [c], the per-file // -// [y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing +// [y] yes [n] no [a] yes to this and all remaining [w] write, apply chosen so far [q] quit, apply nothing // // prompt - the same shape as review.go's reviewChains/reviewPerFile, over a // different plan shape (approved is keyed by index into files, not by @@ -386,7 +386,7 @@ func reviewUndoPerFile(in io.Reader, out io.Writer, files []engine.UndoFile, p p continue } - for _, l := range wrapped(" ", perFileKeys, 2, widthPolicy(out), p.keys) { + for _, l := range wrapped(" ", undoPerFileKeys, 2, widthPolicy(out), p.keys) { fmt.Fprintln(out, l) } for { @@ -402,12 +402,12 @@ func reviewUndoPerFile(in io.Reader, out io.Writer, files []engine.UndoFile, p p case 'a': approved[i] = true yesRest = true - case 'd': + case 'w': return approved, false, nil case 'q': return nil, true, nil default: - fmt.Fprintf(out, "%q is not y, n, a, d or q\n", key) + fmt.Fprintf(out, "%q is not y, n, a, w or q\n", key) continue } break @@ -416,6 +416,10 @@ func reviewUndoPerFile(in io.Reader, out io.Writer, files []engine.UndoFile, p p return approved, false, nil } +// undoPerFileKeys is undo's per-file prompt: review's without [t] and [d], +// which make no sense for a reversal. +const undoPerFileKeys = "[y] yes [n] no [a] yes to this and all remaining [w] write, apply chosen so far [q] quit, apply nothing" + // approveAllUndo approves every reversible file in files by index - [a] // apply all, at either the top level or mid per-file review. A refused file // is never marked true: nothing would happen to it anyway (ApplyUndo skips |
