summaryrefslogtreecommitdiff
path: root/internal/apply/apply.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 20:14:47 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 20:14:47 +0200
commit3f8679be9373ee7508d512dfdfc1dda0839c7f90 (patch)
treeec02eb075f6c4e90f21baa2fe674e86a2f7f6a62 /internal/apply/apply.go
parent24a84671ace373ae331fa83a1ff484990f4dff0e (diff)
downloadkrino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.tar.gz
krino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.zip
krino: acting — trash, journal, apply, lock, review, undo
Diffstat (limited to 'internal/apply/apply.go')
-rw-r--r--internal/apply/apply.go209
1 files changed, 209 insertions, 0 deletions
diff --git a/internal/apply/apply.go b/internal/apply/apply.go
new file mode 100644
index 0000000..2cd52e4
--- /dev/null
+++ b/internal/apply/apply.go
@@ -0,0 +1,209 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// Package apply is the executor: it carries out one file's plan.Chain,
+// actually moving, copying, renaming, trashing or permanently deleting real
+// files. See docs/design.md §7.2 for the mechanism each action follows and
+// §7.4's last paragraph for the execution-time conflict re-check.
+package apply
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "krino/internal/plan"
+ "krino/internal/scan"
+ "krino/internal/trash"
+)
+
+// StepResult is what happened to one step, in the order the executor ran
+// them.
+type StepResult struct {
+ Step plan.Step
+ Status string // "ok" | "failed" | "skipped"
+ Detail string // the failure, or why it was skipped
+ Dst string // where the file actually ended up (conflict names can change at execution time)
+ Size int64 // of the file at Dst afterwards
+ ModTime time.Time // of the file at Dst afterwards
+ Entry string // trash entry name, for Trash steps; "" otherwise
+ // DisplacedEntry is the trash entry name of the file this step
+ // displaced; "" when none. Entry and DisplacedEntry describe two
+ // different files: the one being acted on (Entry, only for a Trash-kind
+ // step), and the one that was in this step's way and had to be trashed
+ // first (DisplacedEntry, only when Displaces was set). Spec §7.4's
+ // overwrite policy and §9's "displace" action both depend on this name
+ // being recoverable — it is chosen inside trash.Put, so nothing
+ // downstream of Chain could otherwise re-derive it for undo.
+ DisplacedEntry string
+ Made []string // directories this step created, outermost first
+}
+
+// Chain runs one file's steps in order and stops at the first failure,
+// marking the rest skipped. It never touches a file whose size or mtime no
+// longer matches what the plan recorded.
+func Chain(c plan.Chain) []StepResult {
+ results := make([]StepResult, len(c.Steps))
+ stopped := false
+
+ for i, step := range c.Steps {
+ if stopped {
+ results[i] = StepResult{Step: step, Status: "skipped", Detail: "an earlier step in this chain failed"}
+ continue
+ }
+ if step.Skip != "" {
+ // Planning already decided this step will not run; it must not
+ // be attempted, so no pre-step check, no directory creation, no
+ // touching the file (spec: a step already marked Skip is
+ // reported, not attempted).
+ results[i] = StepResult{Step: step, Status: "skipped", Detail: step.Skip}
+ continue
+ }
+ if err := checkUnchanged(step.Src, c.File); err != nil {
+ results[i] = StepResult{Step: step, Status: "failed", Detail: err.Error()}
+ stopped = true
+ continue
+ }
+
+ res := runStep(step)
+ results[i] = res
+ if res.Status == "failed" {
+ stopped = true
+ }
+ }
+
+ return results
+}
+
+// checkUnchanged is the guard that matters most: before every step, the
+// source is stat'd and compared against the size and mtime the plan
+// recorded for the whole file. A file rewritten or replaced between
+// planning and applying must never be acted on.
+func checkUnchanged(src string, f scan.File) error {
+ fi, err := os.Stat(src)
+ if err != nil {
+ return fmt.Errorf("changed since plan: %w", err)
+ }
+ if fi.Size() != f.Size || !fi.ModTime().Equal(f.ModTime) {
+ return errors.New("changed since plan")
+ }
+ return nil
+}
+
+// runStep dispatches one already-checked, non-skipped step to the code that
+// actually carries it out.
+func runStep(step plan.Step) StepResult {
+ switch step.Kind {
+ case plan.Copy, plan.Move, plan.Rename:
+ return runFileStep(step)
+ case plan.Trash:
+ return runTrashStep(step)
+ case plan.DeletePermanent:
+ return runDeleteStep(step)
+ }
+ panic(fmt.Sprintf("apply: unknown plan.Kind %d", int(step.Kind)))
+}
+
+// runFileStep carries out copy, move and rename. It re-checks the planned
+// destination against the filesystem as it is now (spec §7.4): if something
+// with Displaces set claims the file to trash first, that happens before
+// anything else, and if the displace fails nothing further is attempted for
+// this file. Otherwise, if the planned Dst now exists, the step moves to the
+// next free stem_N.ext and records the real name in Dst rather than
+// overwriting a file the plan never accounted for. Missing destination
+// directories are created and recorded in Made, outermost first, whether or
+// not the step that needed them goes on to succeed.
+func runFileStep(step plan.Step) StepResult {
+ dst := step.Dst
+ var displacedEntry string
+
+ if step.Displaces != "" {
+ // overwrite policy: the file already at dst must be trashed before
+ // this step's own destination name is used, so no free-name search
+ // applies here — the whole point of displacing was to clear this
+ // exact name. The entry name is captured regardless of what happens
+ // next in this step: spec §9 logs "displace" as its own action with
+ // its own line, independent of whether the move/copy/rename that
+ // needed the name then goes on to succeed, so every return below
+ // (failure included) carries it once trashing has succeeded.
+ entry, err := trash.Put(step.Displaces)
+ if err != nil {
+ return StepResult{Step: step, Status: "failed", Detail: "displacing the existing file: " + err.Error()}
+ }
+ displacedEntry = entry
+ } else if _, err := os.Lstat(dst); err == nil {
+ free, err := nextFreeName(dst)
+ if err != nil {
+ return StepResult{Step: step, Status: "failed", Detail: err.Error()}
+ }
+ dst = free
+ } else if !os.IsNotExist(err) {
+ return StepResult{Step: step, Status: "failed", Detail: err.Error()}
+ }
+
+ made, err := mkdirAllTracked(filepath.Dir(dst))
+ if err != nil {
+ return StepResult{Step: step, Status: "failed", Detail: err.Error(), Made: made, DisplacedEntry: displacedEntry}
+ }
+
+ switch step.Kind {
+ case plan.Copy:
+ err = copyFile(step.Src, dst)
+ case plan.Move:
+ err = moveFile(step.Src, dst)
+ case plan.Rename:
+ err = os.Rename(step.Src, dst)
+ }
+ if err != nil {
+ return StepResult{Step: step, Status: "failed", Detail: err.Error(), Made: made, DisplacedEntry: displacedEntry}
+ }
+
+ fi, err := os.Stat(dst)
+ if err != nil {
+ return StepResult{Step: step, Status: "failed", Detail: err.Error(), Made: made, DisplacedEntry: displacedEntry}
+ }
+ return StepResult{Step: step, Status: "ok", Dst: dst, Size: fi.Size(), ModTime: fi.ModTime(), Made: made, DisplacedEntry: displacedEntry}
+}
+
+// runTrashStep carries out (delete): the file goes to the freedesktop.org
+// Trash via trash.Put. A file on a different filesystem from the Trash is
+// not trashed at all; the spec requires the failure to name the two ways
+// forward, since otherwise the user has no path out of it.
+//
+// Dst, Size and ModTime describe the file at trash.Dir()/files/<entry>,
+// even though plan.Step.Dst is always "" for a delete (there is nothing to
+// compute or conflict-check at plan time). That is a deliberate reading of
+// §9 rather than an oversight forced by the empty plan.Step.Dst: those
+// journal columns describe the file at Dst after the step, and after a
+// trash step the file genuinely lives there, so recording it is more
+// useful than an empty column and stays greppable. It also cannot confuse
+// undo: Task 5's refusal condition for reversing a trash step is "the
+// entry is gone, or Src now exists" — it reads Entry and Src, never Dst.
+func runTrashStep(step plan.Step) StepResult {
+ entry, err := trash.Put(step.Src)
+ if err != nil {
+ detail := err.Error()
+ if errors.Is(err, trash.ErrOtherFilesystem) {
+ detail += "; use (delete permanent) or a move instead"
+ }
+ return StepResult{Step: step, Status: "failed", Detail: detail}
+ }
+
+ dst := filepath.Join(trash.Dir(), "files", entry)
+ var size int64
+ var modTime time.Time
+ if fi, err := os.Stat(dst); err == nil {
+ size, modTime = fi.Size(), fi.ModTime()
+ }
+ return StepResult{Step: step, Status: "ok", Dst: dst, Size: size, ModTime: modTime, Entry: entry}
+}
+
+// runDeleteStep carries out (delete permanent): a plain unlink, with no
+// Trash and no way back.
+func runDeleteStep(step plan.Step) StepResult {
+ if err := os.Remove(step.Src); err != nil {
+ return StepResult{Step: step, Status: "failed", Detail: err.Error()}
+ }
+ return StepResult{Step: step, Status: "ok"}
+}