From 3f8679be9373ee7508d512dfdfc1dda0839c7f90 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 12 Sep 2026 20:14:47 +0200 Subject: krino: acting — trash, journal, apply, lock, review, undo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/krino/sort_test.go | 91 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) (limited to 'cmd/krino/sort_test.go') diff --git a/cmd/krino/sort_test.go b/cmd/krino/sort_test.go index a646503..e482f44 100644 --- a/cmd/krino/sort_test.go +++ b/cmd/krino/sort_test.go @@ -2,7 +2,16 @@ package main -import "testing" +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "krino/internal/plan" + "krino/internal/scan" +) // TestRelWidthAndPadCellCountRunes: C4. relWidth and padCell must measure // column width in runes, not bytes, or a name carrying diacritics @@ -21,3 +30,83 @@ func TestRelWidthAndPadCellCountRunes(t *testing.T) { t.Fatalf("padCell(%q, 9) = %q, want %q (already at width: no padding)", "próba.txt", got, want) } } + +// TestActionableChainsAgreesWithCountActing is fix wave item 4 / Minor 5: +// countActing (render.go) and actionableChains used to disagree over a +// chain every one of whose steps is skipped (len(Steps) > 0, but every +// step's own Skip is set) - countActing already excluded it from "to act +// on", while actionableChains's own len(Steps) > 0 check still offered it +// for approval, so a directory could print "N scanned · 0 to act on" and +// then still ask the user to approve a file it had just said there were +// none of. Converged on chainActing (render.go), both must now agree. +func TestActionableChainsAgreesWithCountActing(t *testing.T) { + chains := []plan.Chain{ + {File: scan.File{Rel: "a.txt"}, Steps: []plan.Step{{Kind: plan.Move, Skip: "target exists"}}}, + {File: scan.File{Rel: "b.txt"}, Steps: []plan.Step{{Kind: plan.Move, Dst: "/r/W/b.txt"}}}, + } + if got := countActing(chains); got != 1 { + t.Errorf("countActing = %d, want 1 (a.txt is all-skipped)", got) + } + actionable := actionableChains(chains) + if len(actionable) != 1 || actionable[0].File.Rel != "b.txt" { + t.Errorf("actionableChains = %+v, want only b.txt - an all-skipped chain must never be offered for approval", actionable) + } +} + +// TestAllSkippedDirectoryReportsZeroAndLogsNothing is fix wave item 4 / +// Minor 5 and 6, end to end. Before the fix: a directory whose one file +// matches a rule under (on-conflict skip) - so its single step's own Skip +// is set ("target exists") - printed "0 to act on" (countActing) and then, +// with -y, still ran that chain through Apply anyway (actionableChains' +// own len(Steps) > 0 check approved it regardless), logging a +// run-start/run-end pair holding only a "skipped" entry while the outcome +// line read "0 applied · 0 failed · 0 declined" for a file that had just +// been silently processed. After the fix, the chain is never offered for +// approval, Apply is never even called for this directory, and the journal +// gains nothing at all. +func TestAllSkippedDirectoryReportsZeroAndLogsNothing(t *testing.T) { + h := home(t) + dl := filepath.Join(h, "dl") + if err := os.MkdirAll(filepath.Join(dl, "Out"), 0o755); err != nil { + t.Fatal(err) + } + old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for _, p := range []string{filepath.Join(dl, "a.txt"), filepath.Join(dl, "Out", "a.txt")} { + if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(p, old, old); err != nil { + t.Fatal(err) + } + } + if code, _, errOut := runCLI(t, "init"); code != 0 { + t.Fatal(errOut) + } + if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { + t.Fatal(errOut) + } + rules := "(path \"~/dl\")\n(min-age 0s)\n(on-conflict skip)\n(rule \"r\" (when (type text)) (move \"Out\"))\n" + if err := os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", "dl.conf"), []byte(rules), 0o644); err != nil { + t.Fatal(err) + } + + code, out, errOut := runCLI(t, "-y") + if code != 0 { + t.Fatalf("run: %d %s", code, errOut) + } + if !strings.Contains(out, "1 scanned · 0 to act on") { + t.Errorf("output = %q, want \"0 to act on\"", out) + } + if !strings.Contains(out, zeroOutcome) { + t.Errorf("output = %q, want the honest zero outcome %q", out, zeroOutcome) + } + + logPath := filepath.Join(h, ".local", "state", "krino", "krino.log") + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("reading the journal: %v", err) + } + if len(data) != 0 { + t.Errorf("journal gained entries for a directory with nothing to act on:\n%s", data) + } +} -- cgit v1.3