From adc3410395771609d2db5ee5ae2b9da71115c5ca Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 14 Sep 2026 11:05:13 +0200 Subject: 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. --- CHANGELOG.md | 4 ++ cmd/krino/colour.go | 109 ++++++++++++++++++++++++++++++ cmd/krino/colour_test.go | 164 ++++++++++++++++++++++++++++++++++++++++++++++ cmd/krino/history_test.go | 12 ++-- cmd/krino/log.go | 13 +++- cmd/krino/main.go | 7 +- cmd/krino/render.go | 25 +++---- cmd/krino/render_test.go | 8 +-- cmd/krino/review.go | 36 +++------- cmd/krino/review_test.go | 34 +++------- cmd/krino/sort.go | 18 ++--- cmd/krino/undo.go | 44 ++++++------- man/krino.1 | 29 ++++++-- 13 files changed, 393 insertions(+), 110 deletions(-) create mode 100644 cmd/krino/colour.go create mode 100644 cmd/krino/colour_test.go 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 -- cgit v1.3