summaryrefslogtreecommitdiff
path: root/internal/engine/explain_test.go
blob: 83011c5a894edb598072eec0d5afa4260dadaa59 (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
// SPDX-License-Identifier: GPL-3.0-or-later

package engine

import (
	"context"
	"os"
	"path/filepath"
	"reflect"
	"strings"
	"testing"
	"time"

	"krino/internal/plan"
)

// TestExplainReportsCapturesAndChain: explain returns what each matching
// rule captured and the steps the file would get, so a GUI can show
// "{1}=2026" and the destination without planning the whole directory
// (GUI design §1.3).
func TestExplainReportsCapturesAndChain(t *testing.T) {
	h, dl := excludeTree(t, map[string]string{"Screenshot_2026-09-01.png": "x"})
	main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `
(path "~/dl")
(rule "shots" (when (name "^Screenshot_(\d{4})-(\d{2})")) (move "Pictures/{1}-{2}") (stop))
`})
	e, errs := Load(main)
	if len(errs) > 0 {
		t.Fatal(errs)
	}
	x, err := e.ExplainWithChain(context.Background(), filepath.Join(dl, "Screenshot_2026-09-01.png"))
	if err != nil {
		t.Fatal(err)
	}
	if len(x.Rules) != 1 || !reflect.DeepEqual(x.Rules[0].Captures, []string{"Screenshot_2026-09", "2026", "09"}) {
		t.Fatalf("captures = %+v", x.Rules)
	}
	if len(x.Chain) != 1 || x.Chain[0].Kind != plan.Move || !strings.HasSuffix(x.Chain[0].Dst, "/dl/Pictures/2026-09/Screenshot_2026-09-01.png") {
		t.Fatalf("chain = %+v", x.Chain)
	}
}

// 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": `
(path "~/dl")
(ignore "Out/")
(rule "r" (when (name "^a")) (rename "b.pdf") (move "Out"))
`})
	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)
	}
	x, err := e.ExplainWithChain(context.Background(), filepath.Join(dl, "a.pdf"))
	if err != nil {
		t.Fatal(err)
	}
	if len(dp.Chains) != 1 || !reflect.DeepEqual(x.Chain, dp.Chains[0].Steps) {
		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)
	}
}