diff options
| -rw-r--r-- | CHANGELOG.md | 3 | ||||
| -rw-r--r-- | cmd/krino/sort.go | 4 | ||||
| -rw-r--r-- | docs/gui-design.md | 11 | ||||
| -rw-r--r-- | internal/config/enum_test.go | 52 | ||||
| -rw-r--r-- | internal/config/load.go | 45 | ||||
| -rw-r--r-- | internal/config/load_test.go | 34 | ||||
| -rw-r--r-- | internal/config/print.go | 12 | ||||
| -rw-r--r-- | internal/config/print_test.go | 24 | ||||
| -rw-r--r-- | internal/engine/explain_test.go | 83 | ||||
| -rw-r--r-- | internal/engine/match.go | 23 | ||||
| -rw-r--r-- | internal/engine/session.go | 33 | ||||
| -rw-r--r-- | internal/engine/session_test.go | 90 |
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) + } +} |
