diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-14 10:56:21 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-14 10:56:21 +0200 |
| commit | 1506c7dcd6032c04c1df6f5785b6dd3dfe4511cc (patch) | |
| tree | fb6d573ca8726c1b1d91ba273a284ba666cde3f6 /cmd/krino | |
| parent | ebdd7bb254a0f19f815a644d26ce232de7be0adb (diff) | |
| download | krino-1506c7dcd6032c04c1df6f5785b6dd3dfe4511cc.tar.gz krino-1506c7dcd6032c04c1df6f5785b6dd3dfe4511cc.zip | |
krino: duplicates are found, never deleted
A rule combining (duplicate) with a delete action is refused at load, and a
file that is a duplicate under any duplicate scope its directory uses gets no
delete step from any rule: the plan shows it skipped and the chain continues.
A failed duplicate check blocks the delete too. Tests cover (matched), another
rule's own condition, a test evaluation skipped, two scopes and a failed
check, each checking every copy is still on disk.
Diffstat (limited to 'cmd/krino')
| -rw-r--r-- | cmd/krino/matching_test.go | 4 | ||||
| -rw-r--r-- | cmd/krino/sort_test.go | 101 |
2 files changed, 79 insertions, 26 deletions
diff --git a/cmd/krino/matching_test.go b/cmd/krino/matching_test.go index 69c0eca..25a2993 100644 --- a/cmd/krino/matching_test.go +++ b/cmd/krino/matching_test.go @@ -18,7 +18,7 @@ const dlRules = ` (recursive yes) (min-age 0s) (ignore "*.part") -(rule "dups" (when (duplicate)) (delete) (stop)) +(rule "dups" (when (duplicate)) (move "Dupes") (stop)) (rule "acme" (when (type document) (content "acme ltd")) (move "Work/Acme") (stop)) (rule "images" (when (type image)) (move "Pictures")) (rule "rest" (when (not (matched)) (type text)) (move "Other")) @@ -66,7 +66,7 @@ func TestDryRun(t *testing.T) { "krino: dl ~/dl\n8 scanned · 4 to act on · 2 warnings · ", "\n 1 inv1.txt move → Work/Acme/ acme type txt, content \"acme ltd\"\n", "\n 2 notes.txt move → Other/ rest not matched, type txt\n", - "\n 4 report (1).pdf trash dups duplicate of report.pdf\n", + "\n 4 report (1).pdf move → Dupes/ dups duplicate of report.pdf\n", "\nwarnings\n brochure.doc acme: content unreadable: needs antiword or catdoc, not installed\n", "\nnot acted on: 1 ignored · 1 busy · 2 unmatched (-v lists them)\n", } { diff --git a/cmd/krino/sort_test.go b/cmd/krino/sort_test.go index fa9a7ca..321c9e8 100644 --- a/cmd/krino/sort_test.go +++ b/cmd/krino/sort_test.go @@ -111,14 +111,14 @@ func TestAllSkippedDirectoryReportsZeroAndLogsNothing(t *testing.T) { } } -// 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) { +// 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") @@ -143,33 +143,86 @@ func TestPermanentDeleteOfDuplicatesUnderOverlappingDirKeepsACopy(t *testing.T) t.Fatal(errOut) } rules := "(path \"~/dl\")\n(recursive yes)\n(min-age 0s)\n" + - "(rule \"dups\" (when (duplicate \"Archive\")) (delete permanent))\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) + } +} - var copies []string - err := filepath.WalkDir(dl, func(p string, d os.DirEntry, err error) error { - if err != nil || !d.Type().IsRegular() { - return err +// 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 b, err := os.ReadFile(p); err == nil && string(b) == string(content) { - copies = append(copies, p) + if err := os.WriteFile(p, []byte("%PDF same"), 0o644); err != nil { + t.Fatal(err) } - return nil - }) - if 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) } - if len(copies) == 0 { - t.Fatalf("every copy was deleted (exit %d)\nstdout:\n%s\nstderr:\n%s", code, out, 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("run: %d %s", code, errOut) + t.Fatalf("exit %d: %s", code, errOut) } - if len(copies) != 1 || copies[0] != archived { - t.Errorf("copies left = %v, want only %s", copies, archived) + if !strings.Contains(out, "skipped: a duplicate is never deleted") { + t.Errorf("plan lacks the skipped delete:\n%s", out) } } |
