aboutsummaryrefslogtreecommitdiff
path: root/internal/journal/journal.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/journal/journal.go
parent24a84671ace373ae331fa83a1ff484990f4dff0e (diff)
downloadkrino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.tar.gz
krino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.zip
krino: acting — trash, journal, apply, lock, review, undo
Diffstat (limited to 'internal/journal/journal.go')
-rw-r--r--internal/journal/journal.go162
1 files changed, 162 insertions, 0 deletions
diff --git a/internal/journal/journal.go b/internal/journal/journal.go
new file mode 100644
index 0000000..465cd51
--- /dev/null
+++ b/internal/journal/journal.go
@@ -0,0 +1,162 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// Package journal is the append-only log every krino action is recorded in,
+// and the only record krino undo reads back. See docs/design.md §9.
+package journal
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+ "unicode/utf8"
+)
+
+// Entry is one logged event. Field order here IS the column order in the
+// file; never reorder it.
+type Entry struct {
+ Time time.Time // RFC 3339 with offset
+ Run string // e.g. "20260911T100203-4f2a"
+ Dir string // the directory's name from krino.conf
+ File string // the file's Rel within that directory
+ Step int // 1-based index within the file's chain; 0 for run-start/run-end
+ Action string // run-start mkdir copy move rename trash delete displace run-end, and undo- forms
+ Status string // ok failed skipped declined
+ Rule string
+ Src string
+ Dst string
+ Size int64 // of the file at Dst after the step
+ ModTime time.Time // of the file at Dst after the step
+ Detail string
+}
+
+// NewRunID returns "<t as 20060102T150405>-<4 hex>": a run identifier that
+// sorts lexically by start time and does not collide across runs started in
+// the same second.
+func NewRunID(t time.Time) string {
+ var b [2]byte
+ _, _ = rand.Read(b[:]) // crypto/rand.Read never fails on supported platforms
+ return t.Format("20060102T150405") + "-" + hex.EncodeToString(b[:])
+}
+
+// Writer appends entries to a log file, one line per Append call.
+type Writer struct {
+ f *os.File
+}
+
+// Open opens the log at path for appending, creating its parent directories
+// and the file itself if necessary. It never truncates an existing log.
+func Open(path string) (*Writer, error) {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return nil, fmt.Errorf("journal: %w", err)
+ }
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ return nil, fmt.Errorf("journal: %w", err)
+ }
+ return &Writer{f: f}, nil
+}
+
+// Append writes e as one line and flushes it before returning. The whole
+// line is written with a single Write call so that two concurrent runs
+// appending to the same file cannot interleave a partial line.
+//
+// Time and ModTime are formatted with RFC3339Nano, not RFC3339: spec §9
+// asks for "RFC 3339 with offset", which RFC3339Nano still is (it only adds
+// an optional fractional-second field; a zero-nanosecond time formats
+// identically under both). Task 5's undo needs the fractional seconds: a
+// refusal check comparing a file's current mtime against the mtime this
+// line records must not be fooled by a file rewritten within the same
+// whole second. read.go's parser already accepts fractional seconds under
+// either constant (a documented time.Parse special case for RFC3339), so
+// only this side needed to change.
+func (w *Writer) Append(e Entry) error {
+ line := strings.Join([]string{
+ e.Time.Format(time.RFC3339Nano),
+ escape(e.Run),
+ escape(e.Dir),
+ escape(e.File),
+ strconv.Itoa(e.Step),
+ escape(e.Action),
+ escape(e.Status),
+ escape(e.Rule),
+ escape(e.Src),
+ escape(e.Dst),
+ strconv.FormatInt(e.Size, 10),
+ e.ModTime.Format(time.RFC3339Nano),
+ escape(e.Detail),
+ }, "\t") + "\n"
+ if _, err := w.f.Write([]byte(line)); err != nil {
+ return fmt.Errorf("journal: %w", err)
+ }
+ return nil
+}
+
+// Close closes the underlying file.
+func (w *Writer) Close() error {
+ if err := w.f.Close(); err != nil {
+ return fmt.Errorf("journal: %w", err)
+ }
+ return nil
+}
+
+// escape encodes s so it can never contain a tab or a newline, and so every
+// byte round-trips exactly: \t, \n, \\ are backslash-escaped, and every
+// other control byte or byte that is not part of valid UTF-8 becomes \xNN.
+// A byte loop is used rather than strconv.Quote, which would also escape
+// non-ASCII text and make names like "zażółć" unreadable in the log.
+func escape(s string) string {
+ if !needsEscape(s) {
+ return s
+ }
+ var b strings.Builder
+ b.Grow(len(s) + 8)
+ i := 0
+ for i < len(s) {
+ c := s[i]
+ switch c {
+ case '\t':
+ b.WriteString(`\t`)
+ i++
+ continue
+ case '\n':
+ b.WriteString(`\n`)
+ i++
+ continue
+ case '\\':
+ b.WriteString(`\\`)
+ i++
+ continue
+ }
+ if c < 0x20 || c == 0x7f {
+ fmt.Fprintf(&b, `\x%02x`, c)
+ i++
+ continue
+ }
+ r, size := utf8.DecodeRuneInString(s[i:])
+ if r == utf8.RuneError && size <= 1 {
+ fmt.Fprintf(&b, `\x%02x`, c)
+ i++
+ continue
+ }
+ b.WriteString(s[i : i+size])
+ i += size
+ }
+ return b.String()
+}
+
+// needsEscape reports whether s contains anything escape would change, so
+// the common case (a plain name) avoids allocating a builder.
+func needsEscape(s string) bool {
+ for i := 0; i < len(s); i++ {
+ c := s[i]
+ if c == '\t' || c == '\n' || c == '\\' || c < 0x20 || c == 0x7f || c >= 0x80 {
+ return true
+ }
+ }
+ return false
+}