aboutsummaryrefslogtreecommitdiff
path: root/cmd
diff options
context:
space:
mode:
Diffstat (limited to 'cmd')
-rw-r--r--cmd/krino/cache_test.go47
-rw-r--r--cmd/krino/check.go1
-rw-r--r--cmd/krino/common.go6
-rw-r--r--cmd/krino/explain.go1
-rw-r--r--cmd/krino/history_test.go16
-rw-r--r--cmd/krino/review.go99
-rw-r--r--cmd/krino/review_test.go90
-rw-r--r--cmd/krino/sort.go7
-rw-r--r--cmd/krino/undo.go12
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