// 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) } } // TestPermanentDeleteOfDuplicatesUnderOverlappingDirKeepsACopy 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 permanently deletes // duplicates of anything under Archive. Whatever the verdicts, applying // the plan must leave the content on disk; the expected outcome is that // the archived copy stays and only the loose one goes. func TestPermanentDeleteOfDuplicatesUnderOverlappingDirKeepsACopy(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\")) (delete permanent))\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") var copies []string err := filepath.WalkDir(dl, func(p string, d os.DirEntry, err error) error { if err != nil || !d.Type().IsRegular() { return err } if b, err := os.ReadFile(p); err == nil && string(b) == string(content) { copies = append(copies, p) } return nil }) if err != nil { t.Fatal(err) } if len(copies) == 0 { t.Fatalf("every copy was deleted (exit %d)\nstdout:\n%s\nstderr:\n%s", code, out, errOut) } if code != 0 { t.Fatalf("run: %d %s", code, errOut) } if len(copies) != 1 || copies[0] != archived { t.Errorf("copies left = %v, want only %s", copies, archived) } }