aboutsummaryrefslogtreecommitdiff
path: root/internal/engine/plan_test.go
blob: 91f52a4995c5f77acfba5d924b2b553e62839535 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
// SPDX-License-Identifier: GPL-3.0-or-later

package engine

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"testing"
	"time"

	"git.labunix.xyz/krino/internal/plan"
)

func TestPlanBuildsChainsFromMatchedFiles(t *testing.T) {
	h := sandbox(t)
	os.Mkdir(filepath.Join(h, "dl"), 0o755)
	old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
	for _, n := range []string{"z.pdf", "a.pdf", "notes.txt"} {
		p := filepath.Join(h, "dl", n)
		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)
		}
	}
	main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `
(path "~/dl")
(min-age 0s)
(rule "pdfs" (when (type pdf)) (move "PDF"))
`})
	e, errs := Load(main, "dl")
	if len(errs) > 0 {
		t.Fatal(errs)
	}

	dp, err := e.Plan(context.Background(), e.Dirs[0], plan.NewClaims())
	if err != nil {
		t.Fatal(err)
	}
	if dp.Dir != e.Dirs[0] {
		t.Errorf("Dir = %v, want %v", dp.Dir, e.Dirs[0])
	}
	if dp.Result == nil || len(dp.Result.Matched) != 2 || len(dp.Result.Unmatched) != 1 {
		t.Fatalf("Result = %+v", dp.Result)
	}

	// Chains keep Match's Rel order: "a.pdf" before "z.pdf", regardless of
	// walk or map-iteration order.
	if len(dp.Chains) != 2 {
		t.Fatalf("got %d chains, want 2 (unmatched notes.txt must not appear)", len(dp.Chains))
	}
	if dp.Chains[0].File.Rel != "a.pdf" || dp.Chains[1].File.Rel != "z.pdf" {
		t.Fatalf("chains not in Rel order: %s, %s", dp.Chains[0].File.Rel, dp.Chains[1].File.Rel)
	}

	c := dp.Chains[0]
	if len(c.Steps) != 1 {
		t.Fatalf("a.pdf steps = %+v", c.Steps)
	}
	s := c.Steps[0]
	want := filepath.Join(h, "dl", "PDF", "a.pdf")
	if s.Kind != plan.Move || s.Rule != "pdfs" || s.Dst != want || s.Skip != "" {
		t.Errorf("step = %+v, want Move to %s", s, want)
	}
}

// TestPlanSharesClaimsAcrossDirectories: A3 - plan.Claims is created once
// by the caller and threaded through every Engine.Plan call of a run, so
// two configured directories cannot plan the same final name. Two
// directories each hold a file named "x.txt" and each carry a rule moving
// it to one shared destination outside both roots; calling Plan for both
// with the *same* claims must resolve the second directory's step onto
// "x_1.txt" rather than let it collide on "x.txt" too.
func TestPlanSharesClaimsAcrossDirectories(t *testing.T) {
	h := sandbox(t)
	shared := filepath.Join(h, "elsewhere")
	for _, d := range []string{"d1", "d2"} {
		if err := os.Mkdir(filepath.Join(h, d), 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(filepath.Join(h, d, "x.txt"), []byte("x"), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	dirConf := func(path string) string {
		return fmt.Sprintf(`(path %q)
(min-age 0s)
(rule "r" (when (type txt)) (move %q))
`, path, shared)
	}
	main := writeConfig(t, h, `(include "d1" "d2")`, map[string]string{
		"d1": dirConf("~/d1"),
		"d2": dirConf("~/d2"),
	})
	e, errs := Load(main, "d1", "d2")
	if len(errs) > 0 {
		t.Fatal(errs)
	}

	claims := plan.NewClaims()
	dp1, err := e.Plan(context.Background(), e.Dirs[0], claims)
	if err != nil {
		t.Fatal(err)
	}
	dp2, err := e.Plan(context.Background(), e.Dirs[1], claims)
	if err != nil {
		t.Fatal(err)
	}

	if len(dp1.Chains) != 1 || len(dp1.Chains[0].Steps) != 1 {
		t.Fatalf("d1 chains = %+v", dp1.Chains)
	}
	if len(dp2.Chains) != 1 || len(dp2.Chains[0].Steps) != 1 {
		t.Fatalf("d2 chains = %+v", dp2.Chains)
	}
	got1 := dp1.Chains[0].Steps[0].Dst
	got2 := dp2.Chains[0].Steps[0].Dst
	want1 := filepath.Join(shared, "x.txt")
	want2 := filepath.Join(shared, "x_1.txt")
	if got1 != want1 {
		t.Errorf("d1 dst = %s, want %s", got1, want1)
	}
	if got2 != want2 {
		t.Errorf("d2 dst = %s, want %s (claims must be shared with d1's plan)", got2, want2)
	}
}

func TestPlanReturnsMatchError(t *testing.T) {
	h := sandbox(t)
	main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `
(path "~/dl")
(rule "r" (when (type pdf)) (move "PDF"))
`})
	e, errs := Load(main, "dl")
	if len(errs) > 0 {
		t.Fatal(errs)
	}
	// dl's root ~/dl does not exist: scan.Walk fails, and Plan must
	// propagate that error rather than paper over it with an empty plan.
	if _, err := e.Plan(context.Background(), e.Dirs[0], plan.NewClaims()); err == nil {
		t.Error("want an error when the root does not exist, got nil")
	}
}