aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md3
-rw-r--r--cmd/krino/explain.go3
-rw-r--r--cmd/krino/sort_test.go6
-rw-r--r--internal/engine/exclude_test.go22
-rw-r--r--internal/engine/match.go23
5 files changed, 54 insertions, 3 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5c4c0bd..392d933 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,9 @@
could change its answer; `explain` shows `?`. A document read only in part
answers the keywords found in what was read, and leaves the others
unknown.
+- `krino explain` says when a duplicate's delete would be skipped, no longer
+ removes a directory's unused keyword cache (it holds no lock), and walks
+ the directory only when a duplicate test needs it.
- `{1}` … `{9}` keep the name's diacritics: a folded `name` test still
matches `Lodz`, but `Łódź-faktura.pdf` now files under `Łódź`, not `Lodz`.
- A tool's error message is shown even when it writes more than the
diff --git a/cmd/krino/explain.go b/cmd/krino/explain.go
index 5f9f6de..f65df42 100644
--- a/cmd/krino/explain.go
+++ b/cmd/krino/explain.go
@@ -78,6 +78,9 @@ func cmdExplain(g *globals, args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stdout, "rule %s: %s\n", rt.Rule.Name, status)
printTrace(stdout, rt.Trace)
}
+ if x.NoDelete != "" {
+ fmt.Fprintf(stdout, "\nkrino would skip deleting this file: %s\n", display(x.NoDelete))
+ }
return 0
}
diff --git a/cmd/krino/sort_test.go b/cmd/krino/sort_test.go
index 321c9e8..12edb24 100644
--- a/cmd/krino/sort_test.go
+++ b/cmd/krino/sort_test.go
@@ -225,4 +225,10 @@ func TestDryRunShowsNeverDeletedSkip(t *testing.T) {
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)
+ }
}
diff --git a/internal/engine/exclude_test.go b/internal/engine/exclude_test.go
index ceb8442..cd1cc4b 100644
--- a/internal/engine/exclude_test.go
+++ b/internal/engine/exclude_test.go
@@ -426,3 +426,25 @@ func TestPartlyReadableDocument(t *testing.T) {
t.Errorf("no warning for the keyword the unreadable part might hold")
}
}
+
+// TestExplainLeavesTheCacheAlone: explain never writes the keyword cache -
+// not even to remove one a directory without content tests no longer uses
+// (triage 28e: explain runs without the directory's lock).
+func TestExplainLeavesTheCacheAlone(t *testing.T) {
+ h, dl := excludeTree(t, map[string]string{"a.txt": "x"})
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"})
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ e.CacheDir = filepath.Join(h, "cache")
+ os.MkdirAll(e.CacheDir, 0o700)
+ cache := filepath.Join(e.CacheDir, "dl.cache")
+ os.WriteFile(cache, []byte("old"), 0o600)
+ if _, err := e.Explain(context.Background(), filepath.Join(dl, "a.txt")); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(cache); err != nil {
+ t.Errorf("explain removed the cache: %v", err)
+ }
+}
diff --git a/internal/engine/match.go b/internal/engine/match.go
index 162730b..0e8b3d5 100644
--- a/internal/engine/match.go
+++ b/internal/engine/match.go
@@ -227,6 +227,7 @@ type Explanation struct {
Excludes []ExcludeTrace
Excluded string // the first exclude that matches, which sets the file aside; "" when none does
Rules []RuleTrace
+ NoDelete string // why a delete from the matching rules would be skipped (spec §5.5); "" when it would not
}
// Explain reports, for one file, whether krino's ordinary scan would ever
@@ -266,8 +267,18 @@ func (e *Engine) Explain(ctx context.Context, path string) (*Explanation, error)
excl := e.excludeDirs(d)
skip := explainSkip(d, sf, excl, now)
- run := newMatchRun(e, d, ctx, now, e.filesForExplain(d, sf, excl, now))
- e.openCache(run) // read only: Explain never writes the cache
+ // The directory is walked only for a duplicate test, the one thing that
+ // needs its other files (triage 21).
+ files := []scan.File{sf}
+ if len(d.DupScopes) > 0 {
+ files = e.filesForExplain(d, sf, excl, now)
+ }
+ run := newMatchRun(e, d, ctx, now, files)
+ if len(d.ContentKeywords) > 0 {
+ // Loaded only: openCache would remove the cache of a directory with
+ // no content tests, and Explain holds no lock (triage 28e).
+ e.openCache(run)
+ }
f := newFacts(run, sf)
var excludes []ExcludeTrace
@@ -282,6 +293,7 @@ func (e *Engine) Explain(ctx context.Context, path string) (*Explanation, error)
}
var rules []RuleTrace
+ var matched []RuleMatch
stoppedBy := ""
for _, r := range d.Rules {
if stoppedBy != "" {
@@ -292,14 +304,19 @@ func (e *Engine) Explain(ctx context.Context, path string) (*Explanation, error)
match := trace.Value
if match {
f.matched = true
+ matched = append(matched, RuleMatch{Rule: r})
}
rules = append(rules, RuleTrace{Rule: r, Match: match, Trace: trace})
if match && r.Conf.Stop {
stoppedBy = r.Name
}
}
+ noDel := ""
+ if len(d.DupScopes) > 0 && deletes(matched) {
+ noDel = noDelete(f, d.DupScopes)
+ }
- return &Explanation{Dir: d, File: sf, Skip: skip, Excludes: excludes, Excluded: excluded, Rules: rules}, nil
+ return &Explanation{Dir: d, File: sf, Skip: skip, Excludes: excludes, Excluded: excluded, Rules: rules, NoDelete: noDel}, nil
}
// cacheFingerprint identifies what a cached keyword answer of d depends on