aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 11:05:13 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 11:05:13 +0200
commitadc3410395771609d2db5ee5ae2b9da71115c5ca (patch)
tree453def41e18f607332be1088920f9d0c563603a3
parent0468ce38470aa3ae8092b92d4f77e72d25dfa108 (diff)
downloadkrino-adc3410395771609d2db5ee5ae2b9da71115c5ca.tar.gz
krino-adc3410395771609d2db5ee5ae2b9da71115c5ca.zip
krino: coloured output, and --no-color
One palette type styles the plan table, warnings, headers, outcome counts, prompt keys, undo's refused steps and krino log's (undone), from the 16-colour ANSI palette plus bold and faint only. Widths are measured on the plain text, so columns line up; with colour off the output is unchanged. --no-color works before or after any subcommand, as NO_COLOR does. The two search-and-replace colourings are gone.
-rw-r--r--CHANGELOG.md4
-rw-r--r--cmd/krino/colour.go109
-rw-r--r--cmd/krino/colour_test.go164
-rw-r--r--cmd/krino/history_test.go12
-rw-r--r--cmd/krino/log.go13
-rw-r--r--cmd/krino/main.go7
-rw-r--r--cmd/krino/render.go25
-rw-r--r--cmd/krino/render_test.go8
-rw-r--r--cmd/krino/review.go36
-rw-r--r--cmd/krino/review_test.go34
-rw-r--r--cmd/krino/sort.go18
-rw-r--r--cmd/krino/undo.go44
-rw-r--r--man/krino.129
13 files changed, 393 insertions, 110 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36b50e7..17eb0b6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,10 @@
directory's rules use gets no delete step from any rule. This replaces
0.0.1's known limitation: duplicate conditions with different scopes can no
longer delete every copy of a group, only move copies aside.
+- Coloured output on a terminal, in the terminal's own 16-colour palette:
+ actions by kind, rule names, skipped steps and reasons, warnings, the
+ outcome counts and the prompt keys. `--no-color`, before or after any
+ subcommand, turns it off, as `NO_COLOR` does.
## 0.0.1 — 2026-09-13
diff --git a/cmd/krino/colour.go b/cmd/krino/colour.go
new file mode 100644
index 0000000..ee9055c
--- /dev/null
+++ b/cmd/krino/colour.go
@@ -0,0 +1,109 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package main
+
+import (
+ "fmt"
+ "io"
+ "strings"
+ "unicode/utf8"
+
+ "krino/internal/plan"
+ "krino/internal/tui"
+)
+
+// palette styles text for a terminal (spec §8.2) with SGR codes from the
+// 16-colour ANSI palette plus bold and faint only, so the terminal's own
+// theme decides what they look like. The zero value is plain: every method
+// returns its text unchanged, which keeps output with colour off
+// byte-identical to what krino printed before colour existed.
+type palette struct{ on bool }
+
+func (p palette) style(code, s string) string {
+ if !p.on || s == "" {
+ return s
+ }
+ return "\x1b[" + code + "m" + s + "\x1b[0m"
+}
+
+func (p palette) bold(s string) string { return p.style("1", s) }
+func (p palette) faint(s string) string { return p.style("2", s) }
+func (p palette) bad(s string) string { return p.style("31", s) }
+func (p palette) good(s string) string { return p.style("32", s) }
+func (p palette) warn(s string) string { return p.style("33", s) }
+func (p palette) rule(s string) string { return p.style("34", s) }
+func (p palette) alarm(s string) string { return p.style("1;31", s) }
+
+// keys bolds every one-character [x] key in a prompt line.
+func (p palette) keys(menu string) string {
+ if !p.on {
+ return menu
+ }
+ var b strings.Builder
+ for i := 0; i < len(menu); {
+ if menu[i] == '[' && i+2 < len(menu) && menu[i+2] == ']' {
+ b.WriteString(p.bold(menu[i : i+3]))
+ i += 3
+ continue
+ }
+ b.WriteByte(menu[i])
+ i++
+ }
+ return b.String()
+}
+
+// padStyled styles s and pads it to w runes measured on s itself, so the
+// escape bytes never count toward the column's width.
+func padStyled(s string, w int, style func(string) string) string {
+ pad := w - utf8.RuneCountInString(s)
+ if pad < 0 {
+ pad = 0
+ }
+ return style(s) + strings.Repeat(" ", pad)
+}
+
+// styleAction styles an action cell actionCell rendered for s: a skipped
+// step faint throughout; otherwise only its leading kind word, green for
+// copy, move and rename, yellow for trash, bold red for DELETE permanently.
+func styleAction(p palette, s plan.Step, cell string) string {
+ if s.Skip != "" {
+ return p.faint(cell)
+ }
+ word := s.Kind.String()
+ if !strings.HasPrefix(cell, word) {
+ return cell
+ }
+ var styled string
+ switch s.Kind {
+ case plan.Trash:
+ styled = p.warn(word)
+ case plan.DeletePermanent:
+ styled = p.alarm(word)
+ default:
+ styled = p.good(word)
+ }
+ return styled + cell[len(word):]
+}
+
+// outcome renders the "N applied · N failed · N declined" line: the applied
+// count green and the failed count red, each only when above 0.
+func outcome(p palette, applied, failed, declined int) string {
+ a, f := fmt.Sprint(applied), fmt.Sprint(failed)
+ if applied > 0 {
+ a = p.good(a)
+ }
+ if failed > 0 {
+ f = p.bad(f)
+ }
+ return fmt.Sprintf("%s applied · %s failed · %d declined", a, f, declined)
+}
+
+// colourPolicy is tui.Colour (terminal, and NO_COLOR unset); tests replace
+// it, since no test runs on a terminal.
+var colourPolicy = tui.Colour
+
+// colourOn reports whether output to w is coloured: never with --no-color,
+// otherwise as colourPolicy decides.
+func colourOn(g *globals, w io.Writer) bool {
+ return !g.noColor && colourPolicy(w)
+}
diff --git a/cmd/krino/colour_test.go b/cmd/krino/colour_test.go
new file mode 100644
index 0000000..fa014e6
--- /dev/null
+++ b/cmd/krino/colour_test.go
@@ -0,0 +1,164 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package main
+
+import (
+ "io"
+ "strings"
+ "testing"
+
+ "krino/internal/plan"
+)
+
+func TestPaletteZeroValueIsPlain(t *testing.T) {
+ var p palette
+ for _, got := range []string{p.bold("x"), p.faint("x"), p.warn("x"), p.rule("x"), p.alarm("x"), p.good("x"), p.bad("x")} {
+ if got != "x" {
+ t.Errorf("plain palette styled %q", got)
+ }
+ }
+ if got := p.keys("[a] apply all [q] quit"); got != "[a] apply all [q] quit" {
+ t.Errorf("plain keys = %q", got)
+ }
+}
+
+func TestPaletteStylesWithAnsiSlotsOnly(t *testing.T) {
+ p := palette{on: true}
+ for _, tt := range []struct{ got, want string }{
+ {p.bold("x"), "\x1b[1mx\x1b[0m"},
+ {p.faint("x"), "\x1b[2mx\x1b[0m"},
+ {p.bad("x"), "\x1b[31mx\x1b[0m"},
+ {p.good("x"), "\x1b[32mx\x1b[0m"},
+ {p.warn("x"), "\x1b[33mx\x1b[0m"},
+ {p.rule("x"), "\x1b[34mx\x1b[0m"},
+ {p.alarm("x"), "\x1b[1;31mx\x1b[0m"},
+ {p.bold(""), ""},
+ {p.keys("[a] apply all [q] quit"), "\x1b[1m[a]\x1b[0m apply all \x1b[1m[q]\x1b[0m quit"},
+ } {
+ if tt.got != tt.want {
+ t.Errorf("got %q, want %q", tt.got, tt.want)
+ }
+ }
+}
+
+func TestPadStyledPadsOnPlainWidth(t *testing.T) {
+ p := palette{on: true}
+ if got, want := padStyled("ab", 5, p.rule), "\x1b[34mab\x1b[0m "; got != want {
+ t.Errorf("got %q, want %q", got, want)
+ }
+ if got := padStyled("abcdef", 3, p.rule); got != "\x1b[34mabcdef\x1b[0m" {
+ t.Errorf("over-wide cell = %q", got)
+ }
+}
+
+func TestColourOnHonoursNoColorFlag(t *testing.T) {
+ old := colourPolicy
+ t.Cleanup(func() { colourPolicy = old })
+ colourPolicy = func(io.Writer) bool { return true }
+ if !colourOn(&globals{}, io.Discard) {
+ t.Error("colour off on a terminal without --no-color")
+ }
+ if colourOn(&globals{noColor: true}, io.Discard) {
+ t.Error("colour on despite --no-color")
+ }
+}
+
+// TestNoColorFlagBeforeAndAfterSubcommand: --no-color parses in both
+// positions, and survives a subcommand registering its own flags.
+func TestNoColorFlagBeforeAndAfterSubcommand(t *testing.T) {
+ home(t)
+ if code, _, errOut := runCLI(t, "init"); code != 0 {
+ t.Fatal(errOut)
+ }
+ for _, args := range [][]string{{"--no-color", "check"}, {"check", "--no-color"}} {
+ if code, _, errOut := runCLI(t, args...); code != 0 {
+ t.Errorf("%v: exit %d: %s", args, code, errOut)
+ }
+ }
+ if !strings.Contains(usage, "--no-color") {
+ t.Error("usage does not mention --no-color")
+ }
+}
+
+// TestPlanTableColouredAndAligned: with colour on, each element of the
+// action table carries its style, and stripping the escapes gives exactly
+// the plain rendering, so the columns line up.
+func TestPlanTableColouredAndAligned(t *testing.T) {
+ root := "/r"
+ rows := []planRow{
+ {num: "1", file: "a.pdf", step: plan.Step{Kind: plan.Move, Rule: "acme", Dst: "/r/Work/a.pdf", Reason: "type pdf"}},
+ {step: plan.Step{Kind: plan.Trash, Rule: "old", Skip: "a duplicate is never deleted", Reason: "matched"}},
+ {num: "2", file: "setup.deb", step: plan.Step{Kind: plan.DeletePermanent, Rule: "pkgs", Reason: "age > 90d"}},
+ }
+ for i := range rows {
+ rows[i].actions = actionCell(rows[i].step, root)
+ rows[i].rule = rows[i].step.Rule
+ rows[i].reason = rows[i].step.Reason
+ }
+ var plain, coloured strings.Builder
+ printPlanTable(&plain, rows, palette{})
+ printPlanTable(&coloured, rows, palette{on: true})
+ for _, want := range []string{
+ "\x1b[32mmove\x1b[0m", "\x1b[34macme\x1b[0m", "\x1b[2mtype pdf\x1b[0m",
+ "\x1b[2mtrash ", "\x1b[1;31mDELETE permanently\x1b[0m",
+ } {
+ if !strings.Contains(coloured.String(), want) {
+ t.Errorf("coloured table lacks %q:\n%q", want, coloured.String())
+ }
+ }
+ if got := stripSGR(coloured.String()); got != plain.String() {
+ t.Errorf("stripped coloured table differs from plain:\n%s\nvs\n%s", got, plain.String())
+ }
+}
+
+func TestUndoAndLogColoured(t *testing.T) {
+ p := palette{on: true}
+ if got := colourRefused(" 1 x refused: gone\n", p); !strings.Contains(got, "\x1b[1;31mrefused:\x1b[0m") {
+ t.Errorf("refused not bold red: %q", got)
+ }
+ if got := colourRefused(" 1 x refused: gone\n", palette{}); got != " 1 x refused: gone\n" {
+ t.Errorf("plain palette changed the undo plan: %q", got)
+ }
+ if got := styleUndone("20260914T101203-ab12 2026-09-14 10:12 dl 3 moved (undone)", p); !strings.HasSuffix(got, " \x1b[2m(undone)\x1b[0m") {
+ t.Errorf("(undone) not faint: %q", got)
+ }
+ if got := styleUndone("20260914T101203-ab12 2026-09-14 10:12 dl 3 moved", p); strings.Contains(got, "\x1b[") {
+ t.Errorf("a run that is not undone was styled: %q", got)
+ }
+}
+
+// stripSGR removes every ESC [ ... m sequence.
+func stripSGR(s string) string {
+ var b strings.Builder
+ for i := 0; i < len(s); i++ {
+ if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
+ if j := strings.IndexByte(s[i:], 'm'); j > 0 {
+ i += j
+ continue
+ }
+ }
+ b.WriteByte(s[i])
+ }
+ return b.String()
+}
+
+// TestDryRunColouredAndNoColor drives the CLI with colour forced on: the
+// header is bold and the warnings heading yellow; --no-color in either
+// position gives no escape at all. NO_COLOR is tested in internal/tui,
+// whose Colour colourPolicy is.
+func TestDryRunColouredAndNoColor(t *testing.T) {
+ matchingFixture(t)
+ old := colourPolicy
+ t.Cleanup(func() { colourPolicy = old })
+ colourPolicy = func(io.Writer) bool { return true }
+ _, out, _ := runCLI(t, "-n")
+ if !strings.Contains(out, "\x1b[1mkrino: dl ~/dl\x1b[0m") || !strings.Contains(out, "\x1b[33mwarnings\x1b[0m") {
+ t.Errorf("coloured dry run lacks bold header or yellow warnings:\n%q", out)
+ }
+ for _, args := range [][]string{{"--no-color", "-n"}, {"-n", "--no-color"}} {
+ _, out, _ := runCLI(t, args...)
+ if strings.Contains(out, "\x1b[") {
+ t.Errorf("%v printed an escape:\n%q", args, out)
+ }
+ }
+}
diff --git a/cmd/krino/history_test.go b/cmd/krino/history_test.go
index 23b1e5f..f76ac66 100644
--- a/cmd/krino/history_test.go
+++ b/cmd/krino/history_test.go
@@ -150,7 +150,7 @@ func TestFinalizeUndoPlanMarksUnapprovedAsDeclined(t *testing.T) {
// TestReviewUndoApplyAll: [a] approves every reversible file by index.
func TestReviewUndoApplyAll(t *testing.T) {
- approved, action, err := reviewUndoFiles(strings.NewReader("a"), new(strings.Builder), undoFiles("a", "b"))
+ approved, action, err := reviewUndoFiles(strings.NewReader("a"), new(strings.Builder), undoFiles("a", "b"), palette{})
if err != nil {
t.Fatal(err)
}
@@ -161,11 +161,11 @@ func TestReviewUndoApplyAll(t *testing.T) {
// TestReviewUndoSkipAndQuit: [s] and [q] both approve nothing.
func TestReviewUndoSkipAndQuit(t *testing.T) {
- approved, action, _ := reviewUndoFiles(strings.NewReader("s"), new(strings.Builder), undoFiles("a", "b"))
+ approved, action, _ := reviewUndoFiles(strings.NewReader("s"), new(strings.Builder), undoFiles("a", "b"), palette{})
if action != 's' || len(approved) != 0 {
t.Errorf("[s] = %q %v; want nothing approved", action, approved)
}
- approved, action, _ = reviewUndoFiles(strings.NewReader("q"), new(strings.Builder), undoFiles("a", "b"))
+ approved, action, _ = reviewUndoFiles(strings.NewReader("q"), new(strings.Builder), undoFiles("a", "b"), palette{})
if action != 'q' || len(approved) != 0 {
t.Errorf("[q] = %q %v; want nothing approved", action, approved)
}
@@ -173,7 +173,7 @@ func TestReviewUndoSkipAndQuit(t *testing.T) {
// TestReviewUndoChoosePerFile: [c] then per-file y/n, keyed by index.
func TestReviewUndoChoosePerFile(t *testing.T) {
- approved, action, err := reviewUndoFiles(strings.NewReader("cyn"), new(strings.Builder), undoFiles("a", "b"))
+ approved, action, err := reviewUndoFiles(strings.NewReader("cyn"), new(strings.Builder), undoFiles("a", "b"), palette{})
if err != nil {
t.Fatal(err)
}
@@ -195,7 +195,7 @@ func TestReviewUndoRefusedFileNotPrompted(t *testing.T) {
files := undoFiles("a", "b", "c")
files[1].Refused = "b changed since the run"
out := new(strings.Builder)
- approved, action, err := reviewUndoFiles(strings.NewReader("cyn"), out, files)
+ approved, action, err := reviewUndoFiles(strings.NewReader("cyn"), out, files, palette{})
if err != nil {
t.Fatal(err)
}
@@ -304,7 +304,7 @@ func TestPrintUndoPlan(t *testing.T) {
// TestInvalidKeyReprompts for the undo-specific menu.
func TestReviewUndoInvalidKeyReprompts(t *testing.T) {
out := new(strings.Builder)
- approved, action, err := reviewUndoFiles(strings.NewReader("zs"), out, undoFiles("a"))
+ approved, action, err := reviewUndoFiles(strings.NewReader("zs"), out, undoFiles("a"), palette{})
if err != nil {
t.Fatal(err)
}
diff --git a/cmd/krino/log.go b/cmd/krino/log.go
index 6eb15d6..bcbe1d0 100644
--- a/cmd/krino/log.go
+++ b/cmd/krino/log.go
@@ -86,12 +86,23 @@ func cmdLog(g *globals, args []string, stdout, stderr io.Writer) int {
return 0
}
+ p := palette{on: colourOn(g, stdout)}
for _, r := range runs {
- fmt.Fprintln(stdout, formatRun(r))
+ fmt.Fprintln(stdout, styleUndone(formatRun(r), p))
}
return 0
}
+// styleUndone styles the "(undone)" formatRun appends, faint (spec §8.2);
+// a line without it is returned as is.
+func styleUndone(line string, p palette) string {
+ const mark = " (undone)"
+ if !p.on || !strings.HasSuffix(line, mark) {
+ return line
+ }
+ return strings.TrimSuffix(line, mark) + " " + p.faint("(undone)")
+}
+
// formatRun renders one journal.Run as krino log lists it: id, start time,
// the directories it touched, and its counts (see countsText), with
// "(undone)" appended when a later run has reversed it.
diff --git a/cmd/krino/main.go b/cmd/krino/main.go
index f5fdfea..12fdf5f 100644
--- a/cmd/krino/main.go
+++ b/cmd/krino/main.go
@@ -50,6 +50,7 @@ Sort the files in the directories listed in krino.conf by their rules.
-v also list unmatched, ignored and busy files
--json with -n: print the plan as JSON
-c FILE use FILE instead of ~/.config/krino/krino.conf
+ --no-color never colour the output, as when NO_COLOR is set
-h, --help show this help
--version print the version
`
@@ -57,6 +58,7 @@ Sort the files in the directories listed in krino.conf by their rules.
// globals holds the flags that may appear before or after a subcommand.
type globals struct {
yes, dry, verbose, json bool
+ noColor bool
conf string
}
@@ -108,11 +110,14 @@ func run(args []string, stdout, stderr io.Writer) int {
return cmdSort(g, rest, stdout, stderr)
}
-// flagSet returns a silent flag set with -c bound to g, shared by every command.
+// flagSet returns a silent flag set with -c and --no-color bound to g,
+// shared by every command. Each defaults to the value already parsed, so a
+// flag given before a subcommand survives the subcommand's own flag set.
func flagSet(name string, g *globals) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(io.Discard)
fs.StringVar(&g.conf, "c", g.conf, "")
+ fs.BoolVar(&g.noColor, "no-color", g.noColor, "")
return fs
}
diff --git a/cmd/krino/render.go b/cmd/krino/render.go
index de5489a..d8e6e47 100644
--- a/cmd/krino/render.go
+++ b/cmd/krino/render.go
@@ -25,10 +25,10 @@ const actionKindWidth = len("rename")
// spec §8.2, below the header line cmdSort has already written: a counts
// line, the numbered action table, the warnings section and the "not
// acted on" line, each present only when it has something to show. No
-// colour, pager or prompt: those arrive with the review UI in plan 4,
-// which wraps this same function, hence its plain (io.Writer, *DirPlan,
-// bool) signature.
-func printPlan(w io.Writer, dp *engine.DirPlan, verbose bool) {
+// pager or prompt: those arrive with the review UI in plan 4, which wraps
+// this same function. p styles the table and the warnings (spec §8.2); the
+// zero palette prints plain text.
+func printPlan(w io.Writer, dp *engine.DirPlan, verbose bool, p palette) {
r := dp.Result
// C1 (plan 2): scanned counts matched, unmatched and skipped alike, not
// just matched plus unmatched - spec §8.2's worked example is "266
@@ -47,13 +47,13 @@ func printPlan(w io.Writer, dp *engine.DirPlan, verbose bool) {
if rows := planRows(dp.Chains, dp.Dir.Root); len(rows) > 0 {
fmt.Fprintln(w)
- printPlanTable(w, rows)
+ printPlanTable(w, rows, p)
}
if len(lines) > 0 {
fmt.Fprintln(w)
- fmt.Fprintln(w, "warnings")
- printWarnings(w, lines)
+ fmt.Fprintln(w, p.warn("warnings"))
+ printWarnings(w, lines, p)
}
if line := skipSummaryLine(r, dp.Chains, verbose); line != "" {
@@ -119,6 +119,7 @@ func countActing(chains []plan.Chain) int {
type planRow struct {
num, file, actions, rule, reason string
chainIdx int
+ step plan.Step // the step this row shows, for styling its cells
}
// planRows turns the chains that have at least one step into table rows,
@@ -137,7 +138,7 @@ func planRows(chains []plan.Chain, root string) []planRow {
}
n++
for i, s := range c.Steps {
- row := planRow{actions: actionCell(s, root), rule: s.Rule, reason: s.Reason, chainIdx: ci}
+ row := planRow{actions: actionCell(s, root), rule: s.Rule, reason: s.Reason, chainIdx: ci, step: s}
if i == 0 {
row.num = strconv.Itoa(n)
row.file = c.File.Rel
@@ -219,8 +220,10 @@ func relToRoot(root, dir string) (rel string, ok bool) {
// column is capped at 40 and the actions column at 46. The reason is the
// last column and is never padded. The row number is right-aligned
// (padLeft, not padCell) so the "#" column stays flush as it widens past
-// a single digit - the layout plan 4's review UI inherits unchanged.
-func printPlanTable(w io.Writer, rows []planRow) {
+// a single digit - the layout plan 4's review UI inherits unchanged. p
+// styles the action, rule and reason cells; widths are measured on the
+// plain text, so the columns line up with colour on.
+func printPlanTable(w io.Writer, rows []planRow, p palette) {
nums := make([]string, len(rows))
files := make([]string, len(rows))
actions := make([]string, len(rows))
@@ -241,7 +244,7 @@ func printPlanTable(w io.Writer, rows []planRow) {
fmt.Fprintf(w, " %s %s %s rule\n", padLeft("#", numW), padCell("file", fileW), padCell("actions", actionsW))
for _, r := range rows {
- fmt.Fprintf(w, " %s %s %s %s %s\n", padLeft(r.num, numW), padCell(r.file, fileW), padCell(r.actions, actionsW), padCell(r.rule, ruleW), r.reason)
+ fmt.Fprintf(w, " %s %s %s %s %s\n", padLeft(r.num, numW), padCell(r.file, fileW), styleAction(p, r.step, padCell(r.actions, actionsW)), padStyled(r.rule, ruleW, p.rule), p.faint(r.reason))
}
}
diff --git a/cmd/krino/render_test.go b/cmd/krino/render_test.go
index 0a6ae26..6f29b51 100644
--- a/cmd/krino/render_test.go
+++ b/cmd/krino/render_test.go
@@ -90,7 +90,7 @@ func TestPrintPlan(t *testing.T) {
}
var buf bytes.Buffer
- printPlan(&buf, dp, false)
+ printPlan(&buf, dp, false, palette{})
out := buf.String()
for _, want := range []string{
@@ -138,7 +138,7 @@ func TestPrintPlanSkippedStep(t *testing.T) {
},
}
var buf bytes.Buffer
- printPlan(&buf, dp, false)
+ printPlan(&buf, dp, false, palette{})
out := buf.String()
if !strings.Contains(out, "copy skipped: target exists") {
t.Errorf("skipped step should show its reason in place of the destination:\n%s", out)
@@ -191,7 +191,7 @@ func TestPrintPlanRowNumberAlignment(t *testing.T) {
}
var buf bytes.Buffer
- printPlan(&buf, dp, false)
+ printPlan(&buf, dp, false, palette{})
out := buf.String()
for _, want := range []string{
@@ -235,7 +235,7 @@ func TestPrintPlanCountsFileOnceWithBothWarningKinds(t *testing.T) {
},
}
var buf bytes.Buffer
- printPlan(&buf, dp, false)
+ printPlan(&buf, dp, false, palette{})
out := buf.String()
for _, want := range []string{
diff --git a/cmd/krino/review.go b/cmd/krino/review.go
index 4f5ad05..a6c1b5a 100644
--- a/cmd/krino/review.go
+++ b/cmd/krino/review.go
@@ -6,7 +6,6 @@ import (
"fmt"
"io"
"os"
- "strings"
"krino/internal/plan"
"krino/internal/tui"
@@ -20,9 +19,10 @@ import (
// strings.Reader. root is the directory being reviewed, threaded through to
// reviewChains so a per-file destination renders the same way the
// directory-level table does (root-relative inside root, ~-abbreviated
-// outside it) instead of always falling back to the abbreviated form.
-func reviewDir(out io.Writer, chains []plan.Chain, root string) (map[string]bool, rune, error) {
- return reviewChains(keyReader{stdin}, out, chains, root)
+// 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) {
+ return reviewChains(keyReader{stdin}, out, chains, root, p)
}
// keyReader adapts tui.ReadKey - one key at a time, from a real *os.File -
@@ -57,8 +57,8 @@ func (k keyReader) Read(p []byte) (int, error) {
// keeps 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) (map[string]bool, rune, error) {
- fmt.Fprint(out, "\n[a] apply all [c] choose per file [s] skip this directory [q] quit\n")
+func reviewChains(in io.Reader, out io.Writer, chains []plan.Chain, root string, p palette) (map[string]bool, rune, error) {
+ fmt.Fprint(out, "\n"+p.keys("[a] apply all [c] choose per file [s] skip this directory [q] quit")+"\n")
for {
key, err := readKey(in)
if err != nil {
@@ -72,7 +72,7 @@ func reviewChains(in io.Reader, out io.Writer, chains []plan.Chain, root string)
case 'q':
return map[string]bool{}, 'q', nil
case 'c':
- approved, quit, err := reviewPerFile(in, out, chains, root)
+ approved, quit, err := reviewPerFile(in, out, chains, root, p)
if err != nil {
return nil, 0, err
}
@@ -99,7 +99,7 @@ func reviewChains(in io.Reader, out io.Writer, chains []plan.Chain, root string)
// 2026-09-12): passing "" here always fails filepath.Rel("", dir) and
// silently fell back to the abbreviated form even for a destination inside
// root, which is not what spec §8.3's own worked example shows.
-func reviewPerFile(in io.Reader, out io.Writer, chains []plan.Chain, root string) (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, quit bool, err error) {
approved = map[string]bool{}
yesRest := false
for i, c := range chains {
@@ -110,9 +110,9 @@ func reviewPerFile(in io.Reader, out io.Writer, chains []plan.Chain, root string
fmt.Fprintf(out, "\n[%d/%d] %s\n", i+1, len(chains), c.File.Rel)
for _, s := range c.Steps {
- fmt.Fprintf(out, " %s\n", actionCell(s, root))
+ fmt.Fprintf(out, " %s\n", styleAction(p, s, actionCell(s, root)))
}
- fmt.Fprint(out, " [y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing\n")
+ fmt.Fprint(out, " "+p.keys("[y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing")+"\n")
for {
key, kerr := readKey(in)
@@ -162,19 +162,3 @@ func approveAll(chains []plan.Chain) map[string]bool {
}
return approved
}
-
-// colourDeletePermanently wraps spec §8.2's "DELETE permanently" marker in
-// the terminal's own ANSI red (bold, slot 1 - never hex), the one thing the
-// spec singles out for emphasis (Ruling 2026-09-12/7). It is applied to
-// text render.go's printPlan already produced, rather than threading a
-// colour parameter through the renderer itself: that keeps render.go and
-// its golden-file tests exactly as plan 4 built them. colour is always the
-// caller's own tui.Colour(stdout) decision - with it false this is a no-op,
-// which is what keeps every escape byte out of a plan piped to a file or
-// read by another tool.
-func colourDeletePermanently(text string, colour bool) string {
- if !colour {
- return text
- }
- return strings.ReplaceAll(text, "DELETE permanently", "\x1b[1;31mDELETE permanently\x1b[0m")
-}
diff --git a/cmd/krino/review_test.go b/cmd/krino/review_test.go
index 193a191..6bf5ea7 100644
--- a/cmd/krino/review_test.go
+++ b/cmd/krino/review_test.go
@@ -20,7 +20,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"), "")
+ approved, action, err := reviewChains(strings.NewReader("cyny"), new(strings.Builder), chains("a", "b", "c"), "", palette{})
if err != nil {
t.Fatal(err)
}
@@ -33,11 +33,11 @@ func TestChoosePerFile(t *testing.T) {
}
func TestApplyAllAndSkip(t *testing.T) {
- approved, action, _ := reviewChains(strings.NewReader("a"), new(strings.Builder), chains("a", "b"), "")
+ 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"), "")
+ 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)
}
@@ -45,7 +45,7 @@ func TestApplyAllAndSkip(t *testing.T) {
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"), "")
+ approved, _, _ := reviewChains(strings.NewReader("cyd"), new(strings.Builder), chains("a", "b", "c"), "", palette{})
if !approved["a"] || approved["b"] || approved["c"] {
t.Errorf("approved = %v; want only a", approved)
}
@@ -57,7 +57,7 @@ func TestPerFileDoneStopsAsking(t *testing.T) {
// 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"), "")
+ approved, action, err := reviewChains(strings.NewReader("cyq"), new(strings.Builder), chains("a", "b"), "", palette{})
if err != nil {
t.Fatal(err)
}
@@ -72,7 +72,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"), "")
+ approved, action, err := reviewChains(strings.NewReader("ca"), new(strings.Builder), chains("a", "b", "c"), "", palette{})
if err != nil {
t.Fatal(err)
}
@@ -86,7 +86,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"), "")
+ approved, action, err := reviewChains(strings.NewReader("zs"), out, chains("a"), "", palette{})
if err != nil {
t.Fatal(err)
}
@@ -113,7 +113,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); err != nil {
+ if _, _, err := reviewChains(strings.NewReader("cyy"), out, cs, root, palette{}); err != nil {
t.Fatal(err)
}
text := out.String()
@@ -124,21 +124,3 @@ func TestPerFileDestinationIsRootRelative(t *testing.T) {
t.Errorf("destination outside root should be ~-abbreviated:\n%s", text)
}
}
-
-// TestNoColourEscapeFromColourFalse pins Ruling 7's safety guarantee at the
-// unit that actually decides it: with colour off, colourDeletePermanently
-// must not alter the text at all, and with colour on it must add an escape
-// around exactly the one marker spec §8.2 singles out for emphasis.
-func TestColourDeletePermanently(t *testing.T) {
- plain := " 4 setup-1.2.deb DELETE permanently old-pkgs age 94d\n"
- if got := colourDeletePermanently(plain, false); got != plain {
- t.Errorf("colour=false must leave the text untouched:\n%q", got)
- }
- got := colourDeletePermanently(plain, true)
- if !strings.Contains(got, "\x1b[") {
- t.Errorf("colour=true should add an escape sequence:\n%q", got)
- }
- if !strings.Contains(got, "DELETE permanently") {
- t.Errorf("colour=true should not remove the marker text itself:\n%q", got)
- }
-}
diff --git a/cmd/krino/sort.go b/cmd/krino/sort.go
index 68f9eda..dd056e0 100644
--- a/cmd/krino/sort.go
+++ b/cmd/krino/sort.go
@@ -63,6 +63,7 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
printDiags(stderr, errs)
return 2
}
+ p := palette{on: colourOn(g, stdout)}
// Spec §8.4: with neither -y nor -n, krino asks; asking a non-terminal
// stdin would just hang (or read garbage), so it refuses instead.
@@ -162,7 +163,7 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stdout)
}
printed = true
- fmt.Fprintf(stdout, "krino: %s %s\n", d.Name, xdg.Abbrev(d.Root))
+ fmt.Fprintln(stdout, p.bold(fmt.Sprintf("krino: %s %s", d.Name, xdg.Abbrev(d.Root))))
}
// C3: directory-level warnings go to stderr after the header
// line above, not before it, so on a terminal they read as
@@ -186,9 +187,8 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
// exists for. printPlan is reused as-is (render.go), never
// re-rendered here.
var buf bytes.Buffer
- printPlan(&buf, dp, g.verbose)
- text := colourDeletePermanently(buf.String(), tui.Colour(stdout))
- if err := tui.Page(stdout, text); err != nil {
+ printPlan(&buf, dp, g.verbose, p)
+ if err := tui.Page(stdout, buf.String()); err != nil {
fmt.Fprintf(stderr, "krino: %s: %v\n", d.Name, err)
exit = 1
return false
@@ -212,7 +212,7 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
approved, action = approveAll(actionable), 'a'
} else {
var rerr error
- approved, action, rerr = reviewDir(stdout, actionable, d.Root)
+ approved, action, rerr = reviewDir(stdout, actionable, d.Root, p)
if rerr != nil {
fmt.Fprintf(stderr, "krino: %s: %v\n", d.Name, rerr)
exit = 1
@@ -252,7 +252,7 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
exit = 1
return false
}
- fmt.Fprintf(stdout, "%d applied · %d failed · %d declined\n", res.Applied, res.Failed, res.Declined)
+ fmt.Fprintln(stdout, outcome(p, res.Applied, res.Failed, res.Declined))
// 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 {
@@ -432,15 +432,15 @@ func warnedCount(lines []warnLine) int {
}
// printWarnings lists one line per warning, Rel padded to the widest shown
-// (capped at 40).
-func printWarnings(w io.Writer, lines []warnLine) {
+// (capped at 40), each line styled with p's warning colour.
+func printWarnings(w io.Writer, lines []warnLine, p palette) {
rels := make([]string, len(lines))
for i, l := range lines {
rels[i] = l.rel
}
width := relWidth(rels)
for _, l := range lines {
- fmt.Fprintf(w, " %s %s\n", padCell(l.rel, width), l.text)
+ fmt.Fprintf(w, " %s\n", p.warn(padCell(l.rel, width)+" "+l.text))
}
}
diff --git a/cmd/krino/undo.go b/cmd/krino/undo.go
index c773f9a..cc65eea 100644
--- a/cmd/krino/undo.go
+++ b/cmd/krino/undo.go
@@ -61,6 +61,7 @@ func cmdUndo(g *globals, args []string, stdout, stderr io.Writer) int {
printDiags(stderr, errs)
return 2
}
+ p := palette{on: colourOn(g, stdout)}
// Spec §8.4/§10: with neither -y nor -n, krino asks; a non-terminal
// stdin would just hang, so it refuses instead - the same check
@@ -140,10 +141,10 @@ func cmdUndo(g *globals, args []string, stdout, stderr io.Writer) int {
}()
}
- fmt.Fprintf(stdout, "krino: undo %s\n", up.Run)
+ fmt.Fprintln(stdout, p.bold("krino: undo "+up.Run))
var buf bytes.Buffer
printUndoPlan(&buf, up)
- text := colourRefused(buf.String(), tui.Colour(stdout))
+ text := colourRefused(buf.String(), p)
// Ruling 6 (Task 7), carried over: the plan goes through tui.Page for
// -n as much as for -y and the interactive path.
if err := tui.Page(stdout, text); err != nil {
@@ -169,7 +170,7 @@ func cmdUndo(g *globals, args []string, stdout, stderr io.Writer) int {
approved, action = approveAllUndo(up.Files), 'a'
} else {
var rerr error
- approved, action, rerr = reviewUndoDir(stdout, up.Files)
+ approved, action, rerr = reviewUndoDir(stdout, up.Files, p)
if rerr != nil {
fmt.Fprintf(stderr, "krino: %v\n", rerr)
return 1
@@ -214,7 +215,7 @@ func cmdUndo(g *globals, args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "krino: %v\n", aerr)
return 1
}
- fmt.Fprintf(stdout, "%d applied · %d failed · %d declined\n", res.Applied, res.Failed, res.Declined)
+ fmt.Fprintln(stdout, outcome(p, res.Applied, res.Failed, res.Declined))
if ctx.Err() != nil {
return 130
@@ -312,9 +313,10 @@ func finalizeUndoPlan(up *engine.UndoPlan, approved map[int]bool) *engine.UndoPl
}
// reviewUndoDir drives the interactive review over the real terminal,
-// mirroring review.go's reviewDir for the forward path.
-func reviewUndoDir(out io.Writer, files []engine.UndoFile) (map[int]bool, rune, error) {
- return reviewUndoFiles(keyReader{stdin}, out, files)
+// mirroring review.go's reviewDir for the forward path; p styles the
+// prompts.
+func reviewUndoDir(out io.Writer, files []engine.UndoFile, p palette) (map[int]bool, rune, error) {
+ return reviewUndoFiles(keyReader{stdin}, out, files, p)
}
// reviewUndoFiles is spec §10's approval flow for an undo plan: the
@@ -331,8 +333,8 @@ func reviewUndoDir(out io.Writer, files []engine.UndoFile) (map[int]bool, rune,
// name). action is always one of 'a', 'c', 's' or 'q', with the same [q]
// folding rule reviewChains uses: a [c] session's own [q] becomes the same
// top-level 'q', and approved is emptied to match.
-func reviewUndoFiles(in io.Reader, out io.Writer, files []engine.UndoFile) (map[int]bool, rune, error) {
- fmt.Fprint(out, "\n[a] apply all [c] choose per file [s] skip [q] quit\n")
+func reviewUndoFiles(in io.Reader, out io.Writer, files []engine.UndoFile, p palette) (map[int]bool, rune, error) {
+ fmt.Fprint(out, "\n"+p.keys("[a] apply all [c] choose per file [s] skip [q] quit")+"\n")
for {
key, err := readKey(in)
if err != nil {
@@ -346,7 +348,7 @@ func reviewUndoFiles(in io.Reader, out io.Writer, files []engine.UndoFile) (map[
case 'q':
return map[int]bool{}, 'q', nil
case 'c':
- approved, quit, err := reviewUndoPerFile(in, out, files)
+ approved, quit, err := reviewUndoPerFile(in, out, files, p)
if err != nil {
return nil, 0, err
}
@@ -365,7 +367,7 @@ func reviewUndoFiles(in io.Reader, out io.Writer, files []engine.UndoFile) (map[
// its reason and reverses nothing of it regardless of anything chosen here
// - but it still gets its own [i/N] line, so the numbering accounts for
// every file in the plan, not just the reversible ones.
-func reviewUndoPerFile(in io.Reader, out io.Writer, files []engine.UndoFile) (approved map[int]bool, quit bool, err error) {
+func reviewUndoPerFile(in io.Reader, out io.Writer, files []engine.UndoFile, p palette) (approved map[int]bool, quit bool, err error) {
approved = map[int]bool{}
yesRest := false
for i, f := range files {
@@ -382,7 +384,7 @@ func reviewUndoPerFile(in io.Reader, out io.Writer, files []engine.UndoFile) (ap
continue
}
- fmt.Fprint(out, " [y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing\n")
+ fmt.Fprint(out, " "+p.keys("[y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing")+"\n")
for {
key, kerr := readKey(in)
if kerr != nil {
@@ -520,16 +522,14 @@ func printUndoTable(w io.Writer, files []engine.UndoFile) {
}
}
-// colourRefused highlights "refused:" in the terminal's own ANSI red (bold,
-// slot 1 - never hex), the undo counterpart of sort.go's
-// colourDeletePermanently: the one thing an undo plan singles out for
-// attention is the file or step nothing will be reversed for. Same
-// no-op-when-plain guarantee: with colour false this never touches the
-// text, which is what keeps every escape byte out of a plan piped to a
-// file or read by another tool.
-func colourRefused(text string, colour bool) string {
- if !colour {
+// colourRefused styles every "refused:" in an undo plan bold red (spec
+// §8.2): the one thing an undo plan singles out is the file or step nothing
+// will be reversed for. With the plain palette the text is untouched, which
+// keeps every escape byte out of a plan piped to a file or read by another
+// tool.
+func colourRefused(text string, p palette) string {
+ if !p.on {
return text
}
- return strings.ReplaceAll(text, "refused:", "\x1b[1;31mrefused:\x1b[0m")
+ return strings.ReplaceAll(text, "refused:", p.alarm("refused:"))
}
diff --git a/man/krino.1 b/man/krino.1
index 627a2ee..9e2d274 100644
--- a/man/krino.1
+++ b/man/krino.1
@@ -101,6 +101,28 @@ Use
.Ar file
in place of
.Pa $XDG_CONFIG_HOME/krino/krino.conf .
+.It Fl -no-color
+Never colour the output, as when
+.Ev NO_COLOR
+is set.
+Without it,
+.Nm
+colours its output only when writing to a terminal, using the terminal's
+own 16-colour palette: directory headers bold;
+.Ic copy ,
+.Ic move
+and
+.Ic rename
+green;
+.Ic trash
+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
+.Ic krino log Ns 's
+.Dq (undone)
+faint.
.It Fl h , Fl -help
Print usage and exit.
.It Fl -version
@@ -278,10 +300,9 @@ Never used for
.Fl -json
output.
.It Ev NO_COLOR
-When set, disables the ANSI colour
-.Nm
-otherwise uses on a terminal for a permanent delete and a refused undo
-step.
+When set, disables colour, as
+.Fl -no-color
+does.
.El
.Sh FILES
.Bl -tag -width Ds