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