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
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
|
// SPDX-License-Identifier: GPL-3.0-or-later
package plan
import (
"fmt"
"path/filepath"
"sort"
"strings"
"time"
"krino/internal/config"
"krino/internal/scan"
"krino/internal/xdg"
)
// Claims is the set of destination paths already spoken for, shared across
// every directory planned in one run (A3): two directories competing for
// one destination must resolve the collision at planning time, which needs
// one Claims threaded through every Build call of that run, not a fresh one
// per call.
type Claims struct {
taken claimed
}
// NewClaims returns an empty Claims, ready to pass to Build.
func NewClaims() *Claims {
return &Claims{taken: claimed{}}
}
// Input is one file and the rules that matched it, in match order.
type Input struct {
File scan.File
Rules []RuleMatch
}
// Build turns each file's matching rules into a chain. root is the
// directory's absolute root; now is the start of the run; d is consulted to
// resolve destination conflicts (§7.4). claims is the run-scoped claim set
// (A3): pass the same *Claims to every Build call of one run (every
// directory included) so two directories claiming one destination resolve
// the collision instead of both silently landing on it. claims must not be
// nil - a caller with nothing to share yet still calls NewClaims() itself,
// so an accidentally-unshared claim set can never happen by simply
// forgetting the argument. Files whose chain has no steps are returned with
// an empty Steps slice, so the caller can tell "matched a rule that does
// nothing" from "not matched".
//
// Conflicts are resolved as each step is created, and a claimed destination
// is shared across every file's chain, not just its own: two files
// competing for one name must resolve the same way on every run, so
// resolution proceeds in File.Rel order regardless of the order in which
// in is given. The returned slice still lines up with in, position for
// position.
func Build(root string, in []Input, now time.Time, d Disk, claims *Claims) []Chain {
if claims == nil {
panic("plan: Build requires a non-nil Claims (see NewClaims)")
}
order := make([]int, len(in))
for i := range order {
order[i] = i
}
sort.SliceStable(order, func(i, j int) bool {
return in[order[i]].File.Rel < in[order[j]].File.Rel
})
chains := make([]Chain, len(in))
for _, i := range order {
chains[i] = buildOne(root, in[i], now, d, claims.taken)
}
return chains
}
// buildOne builds the chain for a single file. claim is shared with every
// other file processed by the same Build call.
func buildOne(root string, in Input, now time.Time, d Disk, claim claimed) Chain {
c := Chain{File: in.File}
cur := in.File.Path
var deletedBy string
moves := 0
warnedMove := false
for _, rule := range in.Rules {
reason := strings.Join(rule.Reasons, ", ")
for _, a := range rule.Actions {
kind := stepKind(a.Kind)
if deletedBy != "" {
c.Steps = append(c.Steps, Step{
Kind: kind,
Rule: rule.Name,
Src: cur,
Reason: reason,
Skip: "deleted by rule " + deletedBy,
Conflict: rule.Settings.OnConflict,
})
continue
}
facts := Facts{
Name: filepath.Base(cur),
Captures: rule.Captures,
ModTime: in.File.ModTime,
Now: now,
}
step := Step{Kind: kind, Rule: rule.Name, Src: cur, Reason: reason, Conflict: rule.Settings.OnConflict}
switch a.Kind {
case config.Copy, config.Move:
// D9: a placeholder failure (below) leaves step.Dst empty -
// there was never a destination to compute at all - while a
// conflict-policy skip (via resolveConflict, right after)
// always sets step.Dst: even when the step will not run,
// its would-be destination is a real, already-resolved
// path worth showing.
dest, err := expandDir(a.Arg, facts, root)
if err != nil {
step.Skip = err.Error()
break
}
dst := filepath.Join(dest, filepath.Base(cur))
resolved, skip, displaces := resolveConflict(kind, rule.Settings.OnConflict, cur, dst, d, claim)
step.Dst = resolved
step.Skip = skip
step.Displaces = displaces
if skip == "" {
// C4: the path cur is about to be vacated from (on a
// move) enters neither claim nor any "freed" set, so
// Disk.Exists still reports it occupied for the rest of
// this plan and a later file wanting that exact name
// gets a gratuitous _1. This errs safe - it never lets
// a name be claimed before its file has actually
// vacated it - and stays; do not "fix" it by weakening
// the disk check.
claim[resolved] = true
if a.Kind == config.Move {
cur = resolved
moves++
if moves > 1 && !warnedMove {
c.Warnings = append(c.Warnings, "moved more than once; a (stop) is probably missing")
warnedMove = true
}
}
}
case config.Rename:
name, err := Expand(a.Arg, facts)
if err != nil {
step.Skip = err.Error()
break
}
if strings.ContainsRune(name, '/') {
step.Skip = `rename produced a name containing "/"`
break
}
dst := filepath.Join(filepath.Dir(cur), name)
resolved, skip, displaces := resolveConflict(kind, rule.Settings.OnConflict, cur, dst, d, claim)
step.Dst = resolved
step.Skip = skip
step.Displaces = displaces
if skip == "" {
cur = resolved
claim[resolved] = true
}
case config.Delete, config.DeletePermanent:
deletedBy = rule.Name
}
c.Steps = append(c.Steps, step)
}
}
return c
}
// stepKind maps a config.ActionKind onto its plan.Kind, exhaustively
// (D4): an unrecognised ActionKind panics rather than silently reading as
// config.Delete, matching config.ActionKind.String()'s own exhaustive style
// with an explicit fallback.
func stepKind(k config.ActionKind) Kind {
switch k {
case config.Copy:
return Copy
case config.Move:
return Move
case config.Rename:
return Rename
case config.DeletePermanent:
return DeletePermanent
case config.Delete:
return Trash
}
panic(fmt.Sprintf("plan: unknown config.ActionKind %d", int(k)))
}
// expandDir expands raw (a DEST argument) against facts, then resolves it
// the same way the engine resolves an extra directory: ~ expands, a
// relative path joins root, and the result is cleaned.
func expandDir(raw string, facts Facts, root string) (string, error) {
expanded, err := Expand(raw, facts)
if err != nil {
return "", err
}
return ResolveDir(expanded, root), nil
}
// ResolveDir expands a leading ~ and joins a relative directory to root,
// cleaned. C1: this is the one place that decides where a rule's
// destination resolves to; internal/engine calls it too (its own directory
// walk needs to agree on the same paths), rather than keeping a second,
// separately-maintained copy - plan is the lower layer (engine imports
// plan, so plan must never import engine), so the decision belongs here.
func ResolveDir(raw, root string) string {
p := xdg.Expand(raw)
if !filepath.IsAbs(p) {
p = filepath.Join(root, p)
}
return filepath.Clean(p)
}
|