summaryrefslogtreecommitdiff
path: root/internal/plan/chain.go
blob: bb4876bbdc150fe7d6d6121e1ad77f5ba04cf3d4 (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
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
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
// 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{}}
}

// Claim marks path as spoken for: a later Build call of the run treats it
// as another step's result, never displacing it (spec §7.4).
func (c *Claims) Claim(path string) {
	c.taken[path] = true
}

// Input is one file and the rules that matched it, in match order.
type Input struct {
	File  scan.File
	Rules []RuleMatch
	// NoDelete, when non-empty, skips every delete step of this file with
	// this text as the step's Skip, without ending the chain (spec §5.5
	// rule 2, §7.1).
	NoDelete string
}

// 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
	})

	// Every scanned file's own path counts as spoken for (review M4): no step
	// of this plan may trash another scanned file to make room - it may be
	// about to move away, or be acted on by its own chain - so overwrite
	// takes a free name there, exactly as suffix already did for a path that
	// exists on disk.
	for _, x := range in {
		claims.taken[x.File.Path] = true
	}
	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
				}
				if name == "" || name == "." || name == ".." {
					step.Skip = fmt.Sprintf("rename produced the name %q", name)
					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:
				if in.NoDelete != "" {
					step.Skip = in.NoDelete
					break
				}
				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.
//
// A placeholder may not take the destination out of the directory the
// rule's own text names before it (spec §15.1): a capture or {ext} is part
// of a file name, which can be "..", "~" or empty, and ResolveDir decides
// from the expanded text whether a destination is relative, in the home
// directory or absolute. So the resolved destination must lie at or under
// staticDir of the raw text. A destination with no placeholder is written
// entirely by the rule, and stands as written.
func expandDir(raw string, facts Facts, root string) (string, error) {
	expanded, err := Expand(raw, facts)
	if err != nil {
		return "", err
	}
	resolved := ResolveDir(expanded, root)
	if prefix, templated := StaticPrefix(raw); templated {
		if base := staticDir(prefix, root); !within(resolved, base) {
			return "", fmt.Errorf("destination %q leaves %s through a placeholder", expanded, xdg.Abbrev(base))
		}
	}
	return resolved, nil
}

// staticDir is the directory a destination names before its first
// placeholder, given that text (StaticPrefix): its last complete path
// segment, resolved like any destination. "Work/Acme/{mtime:%Y}" is
// root/Work/Acme, "Work/Ac{1}" is root/Work, "{1}/x" is root itself, "~/{1}"
// is the home directory and "/{1}" is "/".
func staticDir(prefix, root string) string {
	switch i := strings.LastIndexByte(prefix, '/'); {
	case i < 0:
		prefix = ""
	case i == 0:
		prefix = "/"
	default:
		prefix = prefix[:i]
	}
	return ResolveDir(prefix, root)
}

// within reports whether path is dir itself or lies under it.
func within(path, dir string) bool {
	return path == dir || dir == string(filepath.Separator) || strings.HasPrefix(path, dir+string(filepath.Separator))
}

// 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)
}