aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 01:34:45 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 01:34:45 +0200
commitecfaeabf2a92e26c6a521d5fac404a0fff6263b6 (patch)
tree4147dd6809a45fe5b773447352354dbee2295900
parentbe4b1275c76b9cad984dfafaa8023db94eb3eecf (diff)
downloadkrino-ecfaeabf2a92e26c6a521d5fac404a0fff6263b6.tar.gz
krino-ecfaeabf2a92e26c6a521d5fac404a0fff6263b6.zip
milestone 1 review: claims span the run, explain's chain is opt-in and its own, overrides keyed by clean path, splice and enum guards
-rw-r--r--CHANGELOG.md3
-rw-r--r--cmd/krino/sort.go4
-rw-r--r--docs/gui-design.md11
-rw-r--r--internal/config/enum_test.go52
-rw-r--r--internal/config/load.go45
-rw-r--r--internal/config/load_test.go34
-rw-r--r--internal/config/print.go12
-rw-r--r--internal/config/print_test.go24
-rw-r--r--internal/engine/explain_test.go83
-rw-r--r--internal/engine/match.go23
-rw-r--r--internal/engine/session.go33
-rw-r--r--internal/engine/session_test.go90
12 files changed, 380 insertions, 34 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 12d1d50..14d4931 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,9 @@
## Unreleased
+- A directory that applies nothing no longer frees what an earlier directory
+ of the same run put somewhere: every applied destination stays protected
+ from a later `(on-conflict overwrite)` for the whole run.
## 0.0.9 — 2026-09-15
Rules that cannot be decided fail closed in two more places, `krino check`
diff --git a/cmd/krino/sort.go b/cmd/krino/sort.go
index 54d5574..c2038fd 100644
--- a/cmd/krino/sort.go
+++ b/cmd/krino/sort.go
@@ -274,6 +274,10 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
return stopAfterApply(action, nil)
}()
+ // The directory is done, applied or not: the next one plans against
+ // the disk, with only this run's actual results still claimed.
+ sess.FinishDirectory()
+
if quit {
break
}
diff --git a/docs/gui-design.md b/docs/gui-design.md
index d377bb2..a7c5089 100644
--- a/docs/gui-design.md
+++ b/docs/gui-design.md
@@ -55,10 +55,13 @@ Made first, each tested through the CLI's existing tests as well as new ones:
- **Load with overrides.** `engine.LoadWith(mainFile, overrides map[string][]byte, names...)`
loads the configuration with some files' text replaced in memory, so
unsaved editor text is checked exactly as a run would read it.
-- **Explain with outcomes.** `Explain` also returns, per matching rule, the
- capture values, and the chain the file would get (each step's action,
- destination after placeholders and conflicts, skip reason), so the test
- pane shows `{1}=2026` and `→ Pictures/Screenshots/2026-09/`.
+- **Explain with outcomes.** `Explain` returns, per matching rule, the
+ capture values; `ExplainWithChain` also returns the chain this file alone
+ would get (each step's action, destination after placeholders and
+ conflicts with what is on disk, skip reason), so the test pane shows
+ `{1}=2026` and `→ Pictures/Screenshots/2026-09/`. A clash with another
+ file of the same plan is resolved in the Plan tab, not here; the CLI's
+ `explain` does not ask for the chain, since building it can read files.
- **Printer and splice.** `internal/config` gains a printer for rule and
exclude forms (two-space indentation as in `examples/`) and a splice that
replaces one form's byte range (`sexp.Node.Pos`/`End`) and nothing else.
diff --git a/internal/config/enum_test.go b/internal/config/enum_test.go
new file mode 100644
index 0000000..2e5b718
--- /dev/null
+++ b/internal/config/enum_test.go
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package config
+
+import (
+ "strings"
+ "testing"
+
+ "krino/internal/enumtest"
+)
+
+// TestEverySettingValuePrintsAsItselfInAConfig: PrintRule writes settings
+// with their String(), so every Conflict and CaseMode value must print as
+// the word a configuration uses, and parse back as that same value. A value
+// added later without its branch would otherwise print as "Conflict(3)",
+// which krino check refuses (plan 13 review F5).
+func TestEverySettingValuePrintsAsItselfInAConfig(t *testing.T) {
+ for _, c := range []struct {
+ typ, form string
+ printed func(int) string
+ parsed func(*Rule) string
+ }{
+ {"Conflict", "on-conflict", func(i int) string { return Conflict(i).String() },
+ func(r *Rule) string { return r.Settings.OnConflict.String() }},
+ {"CaseMode", "case", func(i int) string { return CaseMode(i).String() },
+ func(r *Rule) string { return r.Settings.Case.String() }},
+ } {
+ names, err := enumtest.Names("settings.go", c.typ)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(names) == 0 {
+ t.Fatalf("no %s values found", c.typ)
+ }
+ for i, name := range names {
+ word := c.printed(i)
+ if strings.ContainsAny(word, "()0123456789") {
+ t.Errorf("%s.String() = %q; give it a word a configuration uses", name, word)
+ continue
+ }
+ src := "(path \"/tmp\")\n(rule \"r\" (" + c.form + " " + word + ") (move \"Out\"))\n"
+ dir, errs := ParseDir("dl", "dl.conf", []byte(src))
+ if len(errs) > 0 {
+ t.Errorf("%s prints as %q, which krino check refuses: %v", name, word, errs)
+ continue
+ }
+ if got := c.parsed(dir.Rules[0]); got != word {
+ t.Errorf("%s prints as %q but parses back as %q", name, word, got)
+ }
+ }
+ }
+}
diff --git a/internal/config/load.go b/internal/config/load.go
index 3811802..14ce635 100644
--- a/internal/config/load.go
+++ b/internal/config/load.go
@@ -8,6 +8,7 @@ import (
"io/fs"
"os"
"path/filepath"
+ "sort"
"krino/internal/sexp"
"krino/internal/xdg"
@@ -20,15 +21,45 @@ type Config struct {
Dirs []*Dir
}
+// unusedOverrides reports every override LoadWith never read: text meant
+// for a file this load does not touch, which would otherwise pass as
+// checked while the file on disk was read instead (plan 13 review F4).
+func unusedOverrides(over map[string][]byte, used map[string]bool, mainFile string) []*Diag {
+ var out []*Diag
+ for file := range over {
+ if !used[file] {
+ out = append(out, &Diag{File: mainFile, Msg: fmt.Sprintf("unsaved text for %s, which this configuration does not read", file)})
+ }
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Msg < out[j].Msg })
+ return out
+}
+
// readSource is the text of file: the caller's override when it has one,
-// else the file's own bytes.
-func readSource(overrides map[string][]byte, file string) ([]byte, error) {
- if src, ok := overrides[file]; ok {
+// else the file's own bytes. It records which overrides were used, so
+// LoadWith can report one it never reached.
+func readSource(overrides map[string][]byte, used map[string]bool, file string) ([]byte, error) {
+ key := filepath.Clean(file)
+ if src, ok := overrides[key]; ok {
+ used[key] = true
return src, nil
}
return os.ReadFile(file)
}
+// cleanOverrides keys the caller's overrides by cleaned path, so a file
+// named with a "." or ".." segment is still recognised as itself.
+func cleanOverrides(overrides map[string][]byte) map[string][]byte {
+ if len(overrides) == 0 {
+ return nil
+ }
+ out := make(map[string][]byte, len(overrides))
+ for file, src := range overrides {
+ out[filepath.Clean(file)] = src
+ }
+ return out
+}
+
// DefaultFile is krino.conf in the XDG config directory.
func DefaultFile() string {
return filepath.Join(xdg.ConfigHome(), "krino", "krino.conf")
@@ -52,7 +83,9 @@ func Load(mainFile string, names ...string) (*Config, []*Diag) {
// checked exactly as a run would read it (GUI design §1.3). A nil map is
// Load.
func LoadWith(mainFile string, overrides map[string][]byte, names ...string) (*Config, []*Diag) {
- src, err := readSource(overrides, mainFile)
+ over := cleanOverrides(overrides)
+ used := map[string]bool{}
+ src, err := readSource(over, used, mainFile)
if errors.Is(err, fs.ErrNotExist) {
return nil, []*Diag{{File: mainFile, Msg: "not found; create it with: krino init"}}
}
@@ -80,7 +113,7 @@ func LoadWith(mainFile string, overrides map[string][]byte, names ...string) (*C
}
for _, name := range want {
file := DirFile(mainFile, name)
- src, err := readSource(overrides, file)
+ src, err := readSource(over, used, file)
if err != nil {
msg := err.Error()
if errors.Is(err, fs.ErrNotExist) {
@@ -93,7 +126,7 @@ func LoadWith(mainFile string, overrides map[string][]byte, names ...string) (*C
errs = append(errs, derrs...)
cfg.Dirs = append(cfg.Dirs, dir)
}
- return cfg, errs
+ return cfg, append(errs, unusedOverrides(over, used, mainFile)...)
}
// Resolved is the settings that apply in dir: built-in, then the main
diff --git a/internal/config/load_test.go b/internal/config/load_test.go
index 13ef9d8..d522316 100644
--- a/internal/config/load_test.go
+++ b/internal/config/load_test.go
@@ -142,3 +142,37 @@ func TestLoadWithOverriddenText(t *testing.T) {
t.Errorf("the files on disk were read differently: %v %+v", errs, cfg.Dirs)
}
}
+
+// TestLoadWithReportsAnUnusedOverride: an override whose path does not name
+// a file the load reads - a different spelling of it, or a directory not in
+// include - is reported, instead of the file on disk being read as though
+// the unsaved text were fine (plan 13 review F4).
+func TestLoadWithReportsAnUnusedOverride(t *testing.T) {
+ h := t.TempDir()
+ t.Setenv("HOME", h)
+ main := filepath.Join(h, "krino.conf")
+ os.MkdirAll(filepath.Join(h, "dirs"), 0o755)
+ os.WriteFile(main, []byte("(include \"dl\")\n"), 0o644)
+ os.WriteFile(filepath.Join(h, "dirs", "dl.conf"), []byte("(path \"/tmp\")\n"), 0o644)
+
+ broken := []byte("(path \"/tmp\")\n(rule \"BROKEN\")\n")
+ // The same file, spelled with a "." segment: the text must still be used.
+ uncleaned := filepath.Join(h, "dirs", ".", "dl.conf")
+ if _, errs := LoadWith(main, map[string][]byte{uncleaned: broken}); len(errs) == 0 {
+ t.Error("an override keyed by an uncleaned path was ignored")
+ }
+ // A file this load never reads: say so rather than pass silently.
+ other := filepath.Join(h, "dirs", "other.conf")
+ errs := diagText(func() []*Diag { _, e := LoadWith(main, map[string][]byte{other: broken}); return e }())
+ if !strings.Contains(errs, "other.conf") {
+ t.Errorf("an override for a file that is not read went unreported: %s", errs)
+ }
+}
+
+func diagText(ds []*Diag) string {
+ var b strings.Builder
+ for _, d := range ds {
+ b.WriteString(d.Error() + "\n")
+ }
+ return b.String()
+}
diff --git a/internal/config/print.go b/internal/config/print.go
index 5c3d467..a2244d0 100644
--- a/internal/config/print.go
+++ b/internal/config/print.go
@@ -3,6 +3,7 @@
package config
import (
+ "fmt"
"strings"
"krino/internal/sexp"
@@ -59,12 +60,17 @@ func PrintExclude(x *Exclude) string {
// Splice replaces the bytes of the form between start and end - a form's
// Pos and End - with text, and returns the new file contents. Every other
-// byte of src, comments and layout included, is kept exactly.
-func Splice(src []byte, start, end sexp.Pos, text string) []byte {
+// byte of src, comments and layout included, is kept exactly. Offsets that
+// do not belong to src - a form parsed from text that has since changed -
+// are an error, not a panic (plan 13 review F6).
+func Splice(src []byte, start, end sexp.Pos, text string) ([]byte, error) {
+ if start.Offset < 0 || end.Offset < start.Offset || end.Offset > len(src) {
+ return nil, fmt.Errorf("config: splice %d:%d is not inside %d bytes", start.Offset, end.Offset, len(src))
+ }
out := make([]byte, 0, len(src)-(end.Offset-start.Offset)+len(text))
out = append(out, src[:start.Offset]...)
out = append(out, text...)
- return append(out, src[end.Offset:]...)
+ return append(out, src[end.Offset:]...), nil
}
// printAction renders one action form.
diff --git a/internal/config/print_test.go b/internal/config/print_test.go
index 9b8d479..510fdd9 100644
--- a/internal/config/print_test.go
+++ b/internal/config/print_test.go
@@ -5,6 +5,8 @@ package config
import (
"strings"
"testing"
+
+ "krino/internal/sexp"
)
func parseOne(t *testing.T, body string) *Dir {
@@ -70,7 +72,11 @@ func TestSpliceLeavesTheRestAlone(t *testing.T) {
if len(errs) > 0 {
t.Fatal(errs)
}
- got := string(Splice(src, dir.Rules[0].Pos, dir.Rules[0].End, "(rule \"a\"\n (move \"In\"))"))
+ spliced, err := Splice(src, dir.Rules[0].Pos, dir.Rules[0].End, "(rule \"a\"\n (move \"In\"))")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := string(spliced)
want := "; keep me\n(path \"/tmp\")\n(rule \"a\"\n (move \"In\"))\n; and me\n"
if got != want {
t.Errorf("got\n%q\nwant\n%q", got, want)
@@ -79,3 +85,19 @@ func TestSpliceLeavesTheRestAlone(t *testing.T) {
t.Error("Splice changed the source it was given")
}
}
+
+// TestSpliceRefusesOffsetsOutsideTheSource: a stale end offset - the file
+// changed since the form was parsed - is an error, not a panic (plan 13
+// review F6).
+func TestSpliceRefusesOffsetsOutsideTheSource(t *testing.T) {
+ src := []byte("(path \"/tmp\")")
+ for _, c := range []struct{ start, end int }{{0, len(src) + 2}, {8, 4}, {-1, 3}} {
+ if _, err := Splice(src, sexp.Pos{Offset: c.start}, sexp.Pos{Offset: c.end}, "x"); err == nil {
+ t.Errorf("Splice(%d, %d) did not refuse", c.start, c.end)
+ }
+ }
+ out, err := Splice(src, sexp.Pos{Offset: 0}, sexp.Pos{Offset: len(src)}, "(path \"/x\")")
+ if err != nil || string(out) != "(path \"/x\")" {
+ t.Errorf("Splice over the whole source = %q, %v", out, err)
+ }
+}
diff --git a/internal/engine/explain_test.go b/internal/engine/explain_test.go
index 4d304f9..83011c5 100644
--- a/internal/engine/explain_test.go
+++ b/internal/engine/explain_test.go
@@ -4,10 +4,12 @@ package engine
import (
"context"
+ "os"
"path/filepath"
"reflect"
"strings"
"testing"
+ "time"
"krino/internal/plan"
)
@@ -26,7 +28,7 @@ func TestExplainReportsCapturesAndChain(t *testing.T) {
if len(errs) > 0 {
t.Fatal(errs)
}
- x, err := e.Explain(context.Background(), filepath.Join(dl, "Screenshot_2026-09-01.png"))
+ x, err := e.ExplainWithChain(context.Background(), filepath.Join(dl, "Screenshot_2026-09-01.png"))
if err != nil {
t.Fatal(err)
}
@@ -38,8 +40,9 @@ func TestExplainReportsCapturesAndChain(t *testing.T) {
}
}
-// TestExplainChainMatchesThePlan: the chain explain reports is the one the
-// directory's plan gives the same file, conflicts and skips included.
+// TestExplainChainMatchesThePlan: for a file planned on its own, the chain
+// is the one the directory's plan gives it, conflicts with files already on
+// disk included.
func TestExplainChainMatchesThePlan(t *testing.T) {
h, dl := excludeTree(t, map[string]string{"a.pdf": "one", "Out/a.pdf": "taken"})
main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `
@@ -55,7 +58,7 @@ func TestExplainChainMatchesThePlan(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- x, err := e.Explain(context.Background(), filepath.Join(dl, "a.pdf"))
+ x, err := e.ExplainWithChain(context.Background(), filepath.Join(dl, "a.pdf"))
if err != nil {
t.Fatal(err)
}
@@ -63,3 +66,75 @@ func TestExplainChainMatchesThePlan(t *testing.T) {
t.Errorf("explain chain %+v\nplan chain %+v", x.Chain, dp.Chains[0].Steps)
}
}
+
+// TestExplainChainOnlyWhenAsked: the command line's explain does not build
+// the chain (it can hash files to resolve a conflict), and a file no rule
+// acts on has none at all (plan 13 review F1, F2).
+func TestExplainChainOnlyWhenAsked(t *testing.T) {
+ h, dl := excludeTree(t, map[string]string{"a.pdf": "one", "keep-b.pdf": "two", "new.pdf": "three"})
+ now := time.Now()
+ os.Chtimes(filepath.Join(dl, "new.pdf"), now, now)
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `
+(path "~/dl")
+(exclude (name "^keep-"))
+(rule "all" (move "Out"))
+`})
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ x, err := e.Explain(context.Background(), filepath.Join(dl, "a.pdf"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if x.Chain != nil {
+ t.Errorf("Explain built a chain: %+v", x.Chain)
+ }
+ if x, err = e.ExplainWithChain(context.Background(), filepath.Join(dl, "a.pdf")); err != nil || len(x.Chain) != 1 {
+ t.Fatalf("ExplainWithChain: %+v, %v", x.Chain, err)
+ }
+ for _, rel := range []string{"keep-b.pdf", "new.pdf"} {
+ x, err := e.ExplainWithChain(context.Background(), filepath.Join(dl, rel))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if x.Chain != nil {
+ t.Errorf("%s (%s%s) has a chain: %+v", rel, x.Skip, x.Excluded, x.Chain)
+ }
+ }
+}
+
+// TestExplainChainIsThisFileAlone: with two files competing for one name,
+// the chain shows what this file alone would do; the plan is where the two
+// are resolved against each other (plan 13 review F2).
+func TestExplainChainIsThisFileAlone(t *testing.T) {
+ h, dl := excludeTree(t, map[string]string{"a.pdf": "one", "b.pdf": "two"})
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `
+(path "~/dl")
+(rule "r" (rename "same.pdf"))
+`})
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ dp, err := e.Plan(context.Background(), e.Dirs[0], plan.NewClaims())
+ if err != nil {
+ t.Fatal(err)
+ }
+ var planned string
+ for _, c := range dp.Chains {
+ if c.File.Rel == "b.pdf" {
+ planned = filepath.Base(c.Steps[0].Dst)
+ }
+ }
+ if planned != "same_1.pdf" {
+ t.Fatalf("the plan gave b.pdf %q; expected the suffix", planned)
+ }
+ x, err := e.ExplainWithChain(context.Background(), filepath.Join(dl, "b.pdf"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := filepath.Base(x.Chain[0].Dst); got != "same.pdf" {
+ t.Errorf("chain for b.pdf alone = %q, want same.pdf (the plan resolves the clash with a.pdf)", got)
+ }
+}
diff --git a/internal/engine/match.go b/internal/engine/match.go
index 591f686..59cea42 100644
--- a/internal/engine/match.go
+++ b/internal/engine/match.go
@@ -251,9 +251,12 @@ type Explanation struct {
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
- // Chain is what the matching rules would do to the file: the same steps
- // the directory's plan builds for it, placeholders expanded and
- // conflicts resolved (GUI design §1.3).
+ // Chain is what the matching rules would do to this file alone:
+ // placeholders expanded and conflicts resolved against the disk, but
+ // not against the other files of a plan, which can still take a name
+ // this chain shows (the plan itself is where those are resolved). It is
+ // nil unless ExplainWithChain asked for it, and nil for a file the scan
+ // would skip or an exclude sets aside, which no rule acts on.
Chain []plan.Step
}
@@ -263,6 +266,18 @@ type Explanation struct {
// match if the file were looked at; a rule reached after an earlier
// matching (stop) is recorded as Stopped, with no trace.
func (e *Engine) Explain(ctx context.Context, path string) (*Explanation, error) {
+ return e.explain(ctx, path, false)
+}
+
+// ExplainWithChain is Explain with Explanation.Chain filled in: the steps
+// this file alone would get. Building them resolves conflicts against the
+// disk, which can read files (a copy whose target holds the same bytes), so
+// the command line's explain does not ask for it (plan 13 review F1).
+func (e *Engine) ExplainWithChain(ctx context.Context, path string) (*Explanation, error) {
+ return e.explain(ctx, path, true)
+}
+
+func (e *Engine) explain(ctx context.Context, path string, withChain bool) (*Explanation, error) {
abs, err := filepath.Abs(path)
if err != nil {
return nil, err
@@ -358,7 +373,7 @@ func (e *Engine) Explain(ctx context.Context, path string) (*Explanation, error)
}
var chain []plan.Step
- if len(matched) > 0 {
+ if withChain && skip == "" && excluded == "" && len(matched) > 0 {
in := []plan.Input{{File: sf, Rules: planRules(matched), NoDelete: noDel}}
if built := plan.Build(d.Root, in, now, plan.OS{}, plan.NewClaims()); len(built) == 1 {
chain = built[0].Steps
diff --git a/internal/engine/session.go b/internal/engine/session.go
index 33b6a76..d79c364 100644
--- a/internal/engine/session.go
+++ b/internal/engine/session.go
@@ -25,6 +25,7 @@ type Session struct {
j *journal.Writer
run string
claims *plan.Claims
+ landed []string // where this run's applied files ended up, in order
dry bool
}
@@ -90,26 +91,34 @@ func (s *Session) Plan(ctx context.Context, d *Dir) (*DirPlan, error) {
return s.e.Plan(ctx, d, s.claims)
}
-// Apply carries out the approved files of dp and logs the run's steps. A
-// real run applies each directory before the next is planned, so afterwards
-// the disk is the truth for the next one: only the paths this directory's
-// files ended up at stay claimed, which keeps a later (on-conflict
-// overwrite) from displacing this run's own result (spec §7.4). A dry
-// session keeps every claim, since it applies nothing.
+// Apply carries out the approved files of dp and logs the run's steps,
+// remembering where they ended up for FinishDirectory.
func (s *Session) Apply(ctx context.Context, dp *DirPlan, approved map[string]bool) (*ApplyResult, error) {
if err := s.OpenLog(); err != nil {
return nil, err
}
res, err := s.e.Apply(ctx, dp, approved, s.j, s.run)
- if !s.dry {
- s.claims = plan.NewClaims()
- for _, p := range landedAt(res) {
- s.claims.Claim(p)
- }
- }
+ s.landed = append(s.landed, landedAt(res)...)
return res, err
}
+// FinishDirectory ends one directory of a real run: the disk is now the
+// truth for the next one, so the claims start again from where this run's
+// files have actually ended up - every directory's, not just this one's
+// (spec §7.4, plan 13 review F3). A path this directory planned but did not
+// apply is free again; a path it did apply stays protected from a later
+// (on-conflict overwrite) for the rest of the run, even across a directory
+// that applies nothing. A dry run applies nothing and keeps every claim.
+func (s *Session) FinishDirectory() {
+ if s.dry {
+ return
+ }
+ s.claims = plan.NewClaims()
+ for _, p := range s.landed {
+ s.claims.Claim(p)
+ }
+}
+
// landedAt is where res's files ended up: each copy, and the last place a
// move or rename put a file - not a path it passed through and left, and
// nothing at all for a file deleted for good.
diff --git a/internal/engine/session_test.go b/internal/engine/session_test.go
index 8fd4593..98ea1ee 100644
--- a/internal/engine/session_test.go
+++ b/internal/engine/session_test.go
@@ -180,3 +180,93 @@ func TestSessionLockDirsReleasesOnFailure(t *testing.T) {
l.Release()
}
}
+
+// TestSessionKeepsEveryAppliedDestinationClaimed: what a directory's files
+// ended up at stays protected for the whole run, even across a directory
+// that applies nothing (plan 13 review F3): a later (on-conflict overwrite)
+// takes a free name instead of trashing an earlier directory's result.
+func TestSessionKeepsEveryAppliedDestinationClaimed(t *testing.T) {
+ h := sandbox(t)
+ old := time.Now().Add(-2 * time.Hour)
+ dirs := map[string]string{
+ "alpha": "(path \"~/alpha\")\n(rule \"out\" (move \"~/shared\"))\n",
+ "beta": "(path \"~/beta\")\n(rule \"none\" (when (type zzz)) (move \"~/shared\"))\n",
+ "gamma": "(path \"~/gamma\")\n(on-conflict overwrite)\n(rule \"out\" (move \"~/shared\"))\n",
+ }
+ for _, n := range []string{"alpha", "beta", "gamma"} {
+ 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)
+ }
+ main := writeConfig(t, h, `(include "alpha" "beta" "gamma")`, dirs)
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ s, err := e.NewSession(false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+ for _, d := range e.Dirs {
+ dp, err := s.Plan(context.Background(), d)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Apply(context.Background(), dp, map[string]bool{"x.pdf": true}); err != nil {
+ t.Fatal(err)
+ }
+ s.FinishDirectory()
+ }
+ for rel, want := range map[string]string{"shared/x.pdf": "from alpha", "shared/x_1.pdf": "from gamma"} {
+ 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; an earlier directory's result was displaced", len(entries))
+ }
+}
+
+// TestSessionDropsUnappliedClaims: a destination a directory planned but did
+// not apply (declined) is free for the next directory.
+func TestSessionDropsUnappliedClaims(t *testing.T) {
+ h := sandbox(t)
+ old := time.Now().Add(-2 * time.Hour)
+ dirs := map[string]string{
+ "alpha": "(path \"~/alpha\")\n(rule \"out\" (move \"~/shared\"))\n",
+ "beta": "(path \"~/beta\")\n(rule \"out\" (move \"~/shared\"))\n",
+ }
+ for _, n := range []string{"alpha", "beta"} {
+ 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)
+ }
+ main := writeConfig(t, h, `(include "alpha" "beta")`, dirs)
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ s, err := e.NewSession(false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+ dp, err := s.Plan(context.Background(), e.Dirs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Apply(context.Background(), dp, map[string]bool{}); err != nil { // declined
+ t.Fatal(err)
+ }
+ s.FinishDirectory()
+ dp, err = s.Plan(context.Background(), e.Dirs[1])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := dp.Chains[0].Steps[0].Dst; filepath.Base(got) != "x.pdf" {
+ t.Errorf("beta planned %q; the declined destination should be free", got)
+ }
+}