// SPDX-License-Identifier: GPL-3.0-or-later package main 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 // misaligns its column - "próba.txt" is 9 runes but 10 bytes (ó is a // two-byte UTF-8 sequence), one column narrower than its byte length // would suggest. func TestRelWidthAndPadCellCountRunes(t *testing.T) { rels := []string{"a.txt", "próba.txt"} if w := relWidth(rels); w != 9 { t.Fatalf("relWidth(%q) = %d, want 9 (rune count of próba.txt, not its %d bytes)", rels, w, len("próba.txt")) } if got, want := padCell("a.txt", 9), "a.txt "; got != want { t.Fatalf("padCell(%q, 9) = %q, want %q", "a.txt", got, want) } if got, want := padCell("próba.txt", 9), "próba.txt"; got != want { 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) } } // TestDuplicatesUnderOverlappingDirMoveOnlyTheLooseCopy drives the whole run // end to end on the shape where a (duplicate "DIR") overlaps the scanned // tree: a recursive root holding Archive/x.pdf and a loose, older // x-copy.pdf with the same bytes, and a rule that moves duplicates of // anything under Archive aside. Every lookup must elect the same original, // so the archived copy stays where it is and only the loose one is moved; // an inconsistent election would move both. func TestDuplicatesUnderOverlappingDirMoveOnlyTheLooseCopy(t *testing.T) { h := home(t) dl := filepath.Join(h, "dl") content := []byte("%PDF acme statement") archived := filepath.Join(dl, "Archive", "x.pdf") loose := filepath.Join(dl, "x-copy.pdf") old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) for p, mt := range map[string]time.Time{archived: old.Add(time.Hour), loose: old} { if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, content, 0o644); err != nil { t.Fatal(err) } if err := os.Chtimes(p, mt, mt); 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(recursive yes)\n(min-age 0s)\n" + "(rule \"dups\" (when (duplicate \"Archive\")) (move \"~/dupes\") (stop))\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: exit %d\nstdout:\n%s\nstderr:\n%s", code, out, errOut) } moved := filepath.Join(h, "dupes", "x-copy.pdf") for _, p := range []string{archived, moved} { b, err := os.ReadFile(p) if err != nil || string(b) != string(content) { t.Errorf("%s: want the content there, got err %v", p, err) } } if _, err := os.Stat(loose); !os.IsNotExist(err) { t.Errorf("%s is still in place; want it moved to %s", loose, moved) } } // TestCheckRefusesDuplicateWithDelete: krino check reports the §4.5 // refusal on stderr and fails, before any run could act on the rule. func TestCheckRefusesDuplicateWithDelete(t *testing.T) { h := home(t) dl := filepath.Join(h, "dl") if err := os.MkdirAll(dl, 0o755); 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(rule \"d\" (when (duplicate)) (delete))\n" if err := os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", "dl.conf"), []byte(rules), 0o644); err != nil { t.Fatal(err) } code, _, errOut := runCLI(t, "check") want := `rule "d": (duplicate) cannot be combined with (delete)` if code == 0 || !strings.Contains(errOut, want) { t.Errorf("check: exit %d, stderr %q; want non-zero and %q", code, errOut, want) } } // TestDryRunShowsNeverDeletedSkip pins the plan line spec §5.5 names. func TestDryRunShowsNeverDeletedSkip(t *testing.T) { h := home(t) dl := filepath.Join(h, "dl") old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) for name, mt := range map[string]time.Time{"a.pdf": old, "b.pdf": old.Add(time.Hour)} { p := filepath.Join(dl, name) if err := os.MkdirAll(dl, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte("%PDF same"), 0o644); err != nil { t.Fatal(err) } if err := os.Chtimes(p, mt, mt); 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" + "(rule \"dupes\" (when (duplicate)) (move \"Dupes\"))\n" + "(rule \"cleanup\" (when (matched)) (delete))\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, "-n") if code != 0 { t.Fatalf("exit %d: %s", code, errOut) } if !strings.Contains(out, "skipped: a duplicate is never deleted") { t.Errorf("plan lacks the skipped delete:\n%s", out) } // explain shows it too, for the copy that is the duplicate (triage 30a). _, outA, _ := runCLI(t, "explain", filepath.Join(dl, "a.pdf")) _, outB, _ := runCLI(t, "explain", filepath.Join(dl, "b.pdf")) if strings.Count(outA+outB, "a duplicate is never deleted") != 1 { t.Errorf("explain should name the skipped delete for exactly one copy:\n%s\n%s", outA, outB) } } // TestLaterDirectoryIsNotBlockedByAnEarlierOnesClaims: in a real run each // directory is applied before the next is planned, so the disk is the // truth; a path an earlier directory moved a file away from, or planned and // did not apply, is not "taken" for a later one (triage 28i). func TestLaterDirectoryIsNotBlockedByAnEarlierOnesClaims(t *testing.T) { h := home(t) old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) for _, p := range []string{"ca/x.txt", "cb/x.txt"} { full := filepath.Join(h, p) os.MkdirAll(filepath.Dir(full), 0o755) os.WriteFile(full, []byte(p), 0o644) os.Chtimes(full, old, old) } if code, _, errOut := runCLI(t, "init"); code != 0 { t.Fatal(errOut) } for _, n := range []string{"ca", "cb"} { if code, _, errOut := runCLI(t, "new", n, filepath.Join(h, n)); code != 0 { t.Fatal(errOut) } } dirs := filepath.Join(h, ".config", "krino", "dirs") os.WriteFile(filepath.Join(dirs, "ca.conf"), []byte("(path \"~/ca\")\n(rule \"away\" (move \"~/elsewhere\"))\n"), 0o644) os.WriteFile(filepath.Join(dirs, "cb.conf"), []byte("(path \"~/cb\")\n(on-conflict skip)\n(rule \"in\" (move \"~/ca\"))\n"), 0o644) code, out, errOut := runCLI(t, "-y", "ca", "cb") if code != 0 { t.Fatalf("exit %d\n%s\n%s", code, out, errOut) } if b, err := os.ReadFile(filepath.Join(h, "ca", "x.txt")); err != nil || string(b) != "cb/x.txt" { t.Errorf("cb's x.txt did not move into the place ca's left free: %q, %v\n%s", b, err, out) } } // devFullFixture builds two directories, d1 and d2, each with two files a // rule moves, and a log at /dev/full, where every write fails: applying // anything is a genuine apply error, not a step failure. func devFullFixture(t *testing.T) { t.Helper() if _, err := os.Stat("/dev/full"); err != nil { t.Skip("no /dev/full on this system") } h := home(t) old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) if code, _, errOut := runCLI(t, "init"); code != 0 { t.Fatal(errOut) } for _, n := range []string{"d1", "d2"} { for _, name := range []string{"a.pdf", "b.pdf"} { p := filepath.Join(h, n, name) os.MkdirAll(filepath.Dir(p), 0o755) os.WriteFile(p, []byte(n+name), 0o644) os.Chtimes(p, old, old) } if code, _, errOut := runCLI(t, "new", n, filepath.Join(h, n)); code != 0 { t.Fatal(errOut) } os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", n+".conf"), []byte("(path \"~/"+n+"\")\n(rule \"r\" (move \"Out\"))\n"), 0o644) } f, err := os.OpenFile(filepath.Join(h, ".config", "krino", "krino.conf"), os.O_APPEND|os.O_WRONLY, 0) if err != nil { t.Fatal(err) } f.WriteString("(log \"/dev/full\")\n") f.Close() } // TestApplyErrorExitsOne: an error applying a directory - here the log // cannot be written - makes krino exit 1 (triage 34m: removing that exit // code left every test passing). func TestApplyErrorExitsOne(t *testing.T) { devFullFixture(t) code, _, errOut := runCLI(t, "-y", "d1") if code != 1 || !strings.Contains(errOut, "no space left") { t.Errorf("exit %d, stderr %q; want 1 and the write error", code, errOut) } } // TestWriteStopsKrinoWhenApplyFails: [w] in review stops krino after its // directory even when applying it fails - the next directory is not planned // or asked about (review cli F3), driven through the real command with a // pipe standing in for the terminal (triage 28m). func TestWriteStopsKrinoWhenApplyFails(t *testing.T) { devFullFixture(t) r, w, err := os.Pipe() if err != nil { t.Fatal(err) } w.WriteString("cyw") // choose per file, yes to a.pdf, write before b.pdf w.Close() oldStdin, oldTerm := stdin, stdinIsTerminal stdin, stdinIsTerminal = r, func() bool { return true } t.Cleanup(func() { stdin, stdinIsTerminal = oldStdin, oldTerm }) code, out, errOut := runCLI(t, "--no-pager") if code != 1 { t.Errorf("exit %d, want 1\n%s\n%s", code, out, errOut) } if strings.Contains(out, "krino: d2") { t.Errorf("d2 was planned after [w]:\n%s", out) } } // TestLaterDirectoryNeverOverwritesAnEarlierOnesResult: what an earlier // directory of the run put somewhere stays claimed, so a later directory's // (on-conflict overwrite) takes a free name instead of trashing it - as a // dry run of the same two directories shows (plan 11 review M1). func TestLaterDirectoryNeverOverwritesAnEarlierOnesResult(t *testing.T) { h := home(t) old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) if code, _, errOut := runCLI(t, "init"); code != 0 { t.Fatal(errOut) } for _, n := range []string{"a", "b"} { p := filepath.Join(h, n, "x.pdf") os.MkdirAll(filepath.Dir(p), 0o755) os.WriteFile(p, []byte("from "+n), 0o644) os.Chtimes(p, old, old) if code, _, errOut := runCLI(t, "new", n, filepath.Join(h, n)); code != 0 { t.Fatal(errOut) } os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", n+".conf"), []byte("(path \"~/"+n+"\")\n(on-conflict overwrite)\n(rule \"out\" (move \"~/Out\"))\n"), 0o644) } if code, out, errOut := runCLI(t, "-y", "a", "b"); code != 0 { t.Fatalf("exit %d\n%s\n%s", code, out, errOut) } for rel, want := range map[string]string{"Out/x.pdf": "from a", "Out/x_1.pdf": "from b"} { if b, err := os.ReadFile(filepath.Join(h, rel)); err != nil || string(b) != want { t.Errorf("%s: %q, %v; want %q", rel, b, err, want) } } if entries, _ := os.ReadDir(filepath.Join(h, ".local", "share", "Trash", "files")); len(entries) != 0 { t.Errorf("the Trash holds %d entries; nothing should have been displaced", len(entries)) } } // TestOnlyWhereFilesEndedUpStaysClaimed: a path an earlier directory's file // passed through and left - renamed, then moved on - is free for a later // directory; only where files ended up stays claimed (plan 11 re-check). func TestOnlyWhereFilesEndedUpStaysClaimed(t *testing.T) { h := home(t) old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) if code, _, errOut := runCLI(t, "init"); code != 0 { t.Fatal(errOut) } for _, n := range []string{"a", "b"} { p := filepath.Join(h, n, "x.pdf") os.MkdirAll(filepath.Dir(p), 0o755) os.WriteFile(p, []byte("from "+n), 0o644) os.Chtimes(p, old, old) if code, _, errOut := runCLI(t, "new", n, filepath.Join(h, n)); code != 0 { t.Fatal(errOut) } } dirs := filepath.Join(h, ".config", "krino", "dirs") os.WriteFile(filepath.Join(dirs, "a.conf"), []byte("(path \"~/a\")\n(rule \"r\" (rename \"x-r.pdf\") (move \"~/Out\"))\n"), 0o644) os.WriteFile(filepath.Join(dirs, "b.conf"), []byte("(path \"~/b\")\n(rule \"r\" (rename \"x-r.pdf\") (move \"~/a\"))\n"), 0o644) if code, out, errOut := runCLI(t, "-y", "a", "b"); code != 0 { t.Fatalf("exit %d\n%s\n%s", code, out, errOut) } if b, err := os.ReadFile(filepath.Join(h, "a", "x-r.pdf")); err != nil || string(b) != "from b" { t.Errorf("a/x-r.pdf: %q, %v; want b's file under its planned name", b, err) } }