aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md11
-rw-r--r--README.md3
-rw-r--r--cmd/krino/review.go79
-rw-r--r--cmd/krino/review_test.go71
-rw-r--r--cmd/krino/sort.go15
-rw-r--r--cmd/krino/undo.go4
-rw-r--r--docs/design.md17
-rw-r--r--man/krino.119
8 files changed, 185 insertions, 34 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index da39c06..2d6db8b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,17 @@
## Unreleased
+## 0.0.6 — 2026-09-14
+
+- Choosing per file: `w` now applies what was decided and quits krino,
+ instead of going on to the next directory, so a long review can be done a
+ session at a time. Files never reached are not logged as declined; the
+ outcome line counts them as "not reviewed".
+- Each choice is echoed on its own red line under the file (`→ yes`,
+ `→ trash`, ...).
+- Enter is ignored at the review prompts instead of being reported as a
+ wrong key.
+
## 0.0.5 — 2026-09-14
- Keyword cache: for each file whose text it extracts, krino records which
diff --git a/README.md b/README.md
index 2b42628..b62bcfe 100644
--- a/README.md
+++ b/README.md
@@ -132,7 +132,8 @@ key at a time, no Enter needed:
Approval is per file: a file's whole chain runs, or none of it. `t` sends
the file to the Trash and `d` deletes it permanently (after a `y`) instead
-of what the rules planned; `w` applies what you chose so far. A README
+of what the rules planned. Each choice is echoed in red. `w` applies what you
+chose so far and quits, so a long review can be finished another day. A README
can't paste a session it didn't actually run in a terminal, so here we
apply directly with `-y`, which shows the same plan and applies it without
asking:
diff --git a/cmd/krino/review.go b/cmd/krino/review.go
index 29b3915..36231be 100644
--- a/cmd/krino/review.go
+++ b/cmd/krino/review.go
@@ -49,13 +49,16 @@ func (k keyReader) Read(p []byte) (int, error) {
//
// [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 [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
+// prompt. action is always one of 'a', 'c', 's', 'w' 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 [w] ("write, apply chosen so
+// far"), which keeps it and is returned as 'w': the caller applies what was
+// decided and stops. approved holds every file decided, true for yes and
+// false for no; a file [w] left unreviewed is absent. replaced holds the
+// files [t] or [d] chose to trash or delete instead of what the rules
+// planned; replaceChains applies it. Enter is ignored at both prompts. 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, map[string]plan.Kind, rune, error) {
@@ -69,6 +72,8 @@ func reviewChains(in io.Reader, out io.Writer, chains []plan.Chain, root string,
return nil, nil, 0, err
}
switch key {
+ case '\r', '\n':
+ continue
case 'a':
return approveAll(chains), nil, 'a', nil
case 's':
@@ -76,12 +81,15 @@ func reviewChains(in io.Reader, out io.Writer, chains []plan.Chain, root string,
case 'q':
return map[string]bool{}, nil, 'q', nil
case 'c':
- approved, replaced, quit, err := reviewPerFile(in, out, chains, root, p)
+ approved, replaced, end, err := reviewPerFile(in, out, chains, root, p)
if err != nil {
return nil, nil, 0, err
}
- if quit {
+ switch end {
+ case 'q':
return map[string]bool{}, nil, 'q', nil
+ case 'w':
+ return approved, replaced, 'w', nil
}
return approved, replaced, 'c', nil
default:
@@ -97,14 +105,14 @@ func reviewChains(in io.Reader, out io.Writer, chains []plan.Chain, root string,
// just that file; [a] approves it and every remaining file without asking
// 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
+// other key asks about the same file again); every choice is echoed in red
+// on its own line; [w] stops asking and applies whatever was already chosen,
+// leaving the rest unreviewed - reported back as end 'w'; [q] aborts the
+// review entirely, discarding even files already marked yes - end 'q'. 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, replaced map[string]plan.Kind, 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, end rune, err error) {
approved = map[string]bool{}
replaced = map[string]plan.Kind{}
yesRest := false
@@ -128,24 +136,31 @@ func reviewPerFile(in io.Reader, out io.Writer, chains []plan.Chain, root string
}
key, kerr := readKey(in)
if kerr != nil {
- return nil, nil, false, kerr
+ return nil, nil, 0, kerr
}
+ var choice string
switch key {
+ case '\r', '\n':
+ continue
case 'y':
approved[c.File.Rel] = true
+ choice = "yes"
case 'n':
- // leave unapproved
+ approved[c.File.Rel] = false
+ choice = "no"
case 'a':
approved[c.File.Rel] = true
yesRest = true
+ choice = "yes, and all remaining"
case 't':
approved[c.File.Rel] = true
replaced[c.File.Rel] = plan.Trash
+ choice = "trash"
case 'd':
fmt.Fprintf(out, " delete %s permanently? [y/N] ", c.File.Rel)
confirm, kerr := readKey(in)
if kerr != nil {
- return nil, nil, false, kerr
+ return nil, nil, 0, kerr
}
fmt.Fprintln(out)
if confirm != 'y' {
@@ -155,18 +170,42 @@ func reviewPerFile(in io.Reader, out io.Writer, chains []plan.Chain, root string
}
approved[c.File.Rel] = true
replaced[c.File.Rel] = plan.DeletePermanent
+ choice = "DELETE permanently"
case 'w':
- return approved, replaced, false, nil
+ return approved, replaced, 'w', nil
case 'q':
- return nil, nil, true, nil
+ return nil, nil, 'q', nil
default:
fmt.Fprintf(out, "%q is not y, n, a, t, d, w or q\n", key)
continue
}
+ fmt.Fprintln(out, " "+p.bad("→ "+choice))
break
}
}
- return approved, replaced, false, nil
+ return approved, replaced, 0, nil
+}
+
+// reviewedChains returns the chains of the files decided holds, yes or no,
+// in order: after [w], only these go to Apply, so a file never reviewed is
+// neither applied nor logged as declined.
+func reviewedChains(chains []plan.Chain, decided map[string]bool) []plan.Chain {
+ var out []plan.Chain
+ for _, c := range chains {
+ if _, ok := decided[c.File.Rel]; ok {
+ out = append(out, c)
+ }
+ }
+ return out
+}
+
+// withNotReviewed appends to an outcome line how many files [w] left
+// unreviewed, when any were.
+func withNotReviewed(line string, n int) string {
+ if n == 0 {
+ return line
+ }
+ return fmt.Sprintf("%s · %d not reviewed", line, n)
}
// perFileKeys is the per-file prompt of spec §8.3, wrapped to the terminal
diff --git a/cmd/krino/review_test.go b/cmd/krino/review_test.go
index 19a1d01..a1fbba7 100644
--- a/cmd/krino/review_test.go
+++ b/cmd/krino/review_test.go
@@ -46,10 +46,17 @@ func TestApplyAllAndSkip(t *testing.T) {
}
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)
+ // 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)
}
}
@@ -162,7 +169,7 @@ func TestPerFileWrapsToTheTerminal(t *testing.T) {
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, " ")) {
+ if strings.HasPrefix(l, " [y] yes") || (prompt != "" && strings.HasPrefix(l, " ") && !strings.HasPrefix(l, " ") && !strings.HasPrefix(l, " →")) {
prompt += strings.TrimPrefix(l, " ")
}
}
@@ -234,3 +241,57 @@ func TestReplaceChains(t *testing.T) {
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)
+ }
+}
diff --git a/cmd/krino/sort.go b/cmd/krino/sort.go
index 3e5d951..04fd3e8 100644
--- a/cmd/krino/sort.go
+++ b/cmd/krino/sort.go
@@ -247,7 +247,16 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
// [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)
+ // [w]: apply what was decided, log nothing for the files never
+ // reached, and stop krino once this directory is applied.
+ toApply, notReviewed := dp, 0
+ if action == 'w' {
+ reviewed := *dp
+ reviewed.Chains = reviewedChains(dp.Chains, approved)
+ toApply = &reviewed
+ notReviewed = len(actionable) - len(reviewedChains(actionable, approved))
+ }
+ res, aerr := e.Apply(ctx, toApply, approved, j, run)
if aerr != nil {
if errors.Is(aerr, context.Canceled) || errors.Is(aerr, context.DeadlineExceeded) {
// Interrupted mid-apply (fix round 2026-09-12/item 2):
@@ -263,13 +272,13 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
exit = 1
return false
}
- fmt.Fprintln(stdout, outcome(p, res.Applied, res.Failed, res.Declined))
+ fmt.Fprintln(stdout, withNotReviewed(outcome(p, res.Applied, res.Failed, res.Declined), notReviewed))
// Ruling 1: only an actual step failure makes the run exit 1
// here - a directory the user declined or skipped must not.
if res.Failed > 0 {
exit = 1
}
- return false
+ return action == 'w'
}()
if quit {
diff --git a/cmd/krino/undo.go b/cmd/krino/undo.go
index 036e3c0..5e18a7e 100644
--- a/cmd/krino/undo.go
+++ b/cmd/krino/undo.go
@@ -343,6 +343,8 @@ func reviewUndoFiles(in io.Reader, out io.Writer, files []engine.UndoFile, p pal
return nil, 0, err
}
switch key {
+ case '\r', '\n':
+ continue
case 'a':
return approveAllUndo(files), 'a', nil
case 's':
@@ -395,6 +397,8 @@ func reviewUndoPerFile(in io.Reader, out io.Writer, files []engine.UndoFile, p p
return nil, false, kerr
}
switch key {
+ case '\r', '\n':
+ continue
case 'y':
approved[i] = true
case 'n':
diff --git a/docs/design.md b/docs/design.md
index 271e2a6..331993a 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -7,7 +7,8 @@ shown as one block per file, wrapped to the terminal, and `-P` (§8.2, §8.3, §
amended again 2026-09-14 for 0.0.4: `max-size` (§4.4), `(exclude ...)`
(§4.2, §4.3, §4.6) and `--min-age` (§11); amended again 2026-09-14 for
0.0.5: the keyword cache (§3, §6.1, §13), and `t`, `d` and `w` in review
-(§8.3, §10).
+(§8.3, §10); amended again 2026-09-14 for 0.0.6: `w` applies and quits, and
+each choice is echoed in red (§8.2, §8.3).
krino (from Greek κρίνω, "to separate, to judge, to decide") sorts files in
chosen directories by rules. A rule tests a file's type, name, path, size,
@@ -533,6 +534,7 @@ not acted on: 3 busy · 12 ignored · 210 unmatched (-v lists them)
| the `warnings` heading and its lines | yellow |
| outcome line | the applied count green when above 0, the failed count red when above 0 |
| prompt keys `[a]` `[y]` … | bold |
+ | the choice echoed in review, `→ yes` … | red |
| `krino log`'s `(undone)` | faint |
Permanent deletes are always marked in capitals, so they stand out without
@@ -567,8 +569,17 @@ Trash, or deleted permanently. They are the user's own decision, logged
under the rule name `(review)`; `(review)` steps are not subject to the
duplicate protection of §5.5, which governs rules. `d` asks
`delete NAME permanently? [y/N]`, and any key but `y` deletes nothing and
-asks about the file again. `w` stops asking and applies what was chosen so
-far. `q` applies nothing in this directory, choices included.
+asks about the file again. Each choice is confirmed on its own red line
+under the file (`→ yes`, `→ no`, `→ trash`, `→ DELETE permanently`,
+`→ yes, and all remaining`). Enter is ignored.
+
+`w` applies what was decided so far and stops krino, without planning or
+asking about any later directory, so a long review can be done a session at
+a time. Files answered `n` are logged as declined; files never reached are
+not logged and are counted separately: `20 applied · 0 failed · 3 declined ·
+139 not reviewed`. Nothing about a declined file is remembered: the next run
+asks about it again (an `exclude` stops that). `q` applies nothing in this
+directory, choices included, and stops krino.
### 8.4 Modes
diff --git a/man/krino.1 b/man/krino.1
index cc4c2e1..e7d93eb 100644
--- a/man/krino.1
+++ b/man/krino.1
@@ -122,7 +122,7 @@ yellow;
.Sy DELETE permanently
and a refused undo step bold red; skipped steps and match reasons faint;
rule names blue; warnings yellow; the applied count green and a failed count
-red; prompt keys bold; and
+red; prompt keys bold; the choice echoed in review red; and
.Ic krino log Ns 's
.Dq (undone)
faint.
@@ -262,8 +262,23 @@ any key but
.Ic y
deletes nothing and asks about the file again.
A permanent delete cannot be undone.
+Each choice is confirmed on its own line under the file, in red:
+.Dq \(-> yes ,
+.Dq \(-> no ,
+.Dq \(-> trash ,
+and so on.
+Enter is ignored.
+.Pp
.Ic w
-stops asking and applies whatever was already chosen, declining the rest.
+applies what was decided so far and stops
+.Nm ,
+without going on to any later directory, so a long review can be done over
+several sessions.
+Files answered
+.Ic n
+are logged as declined; files never reached are not logged, are counted as
+.Dq not reviewed ,
+and are asked about again next time, as are declined files.
.Ic q
here aborts the review for this directory entirely, discarding even a file
already marked