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