diff options
Diffstat (limited to 'internal/plan/chain.go')
| -rw-r--r-- | internal/plan/chain.go | 222 |
1 files changed, 222 insertions, 0 deletions
diff --git a/internal/plan/chain.go b/internal/plan/chain.go new file mode 100644 index 0000000..bfa2484 --- /dev/null +++ b/internal/plan/chain.go @@ -0,0 +1,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) +} |
