summaryrefslogtreecommitdiff
path: root/internal/journal
diff options
context:
space:
mode:
Diffstat (limited to 'internal/journal')
-rw-r--r--internal/journal/read_test.go214
1 files changed, 214 insertions, 0 deletions
diff --git a/internal/journal/read_test.go b/internal/journal/read_test.go
index 3ffa14d..2f0f40f 100644
--- a/internal/journal/read_test.go
+++ b/internal/journal/read_test.go
@@ -196,6 +196,220 @@ func TestRunsDoesNotMarkUndoneWhenOnlyOkEntryIsMkdir(t *testing.T) {
}
}
+// TestEntriesCrashedRunReturnsNilError is item 1: a run-start present,
+// run-end absent, and otherwise clean is exactly the crashed-run shape
+// Entries' own doc comment says it must accept - "to end of file when there
+// is no run-end (a crashed run, which is precisely when corruption is
+// likely)". Pinning it as its own test, rather than leaving it implicit in
+// tests about something else, is the point of the item.
+func TestEntriesCrashedRunReturnsNilError(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "krino.log")
+ w, _ := Open(path)
+ at := time.Date(2026, 9, 12, 8, 0, 0, 0, time.UTC)
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
+ Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil {
+ t.Fatal(err)
+ }
+ // No run-end: the process crashed right here.
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := Entries(path, "A")
+ if err != nil {
+ t.Fatalf("a crashed but otherwise clean run returned an error: %v", err)
+ }
+ if len(got) != 2 || got[0].Action != "run-start" || got[1].Action != "move" {
+ t.Errorf("entries = %+v, want [run-start, move]", got)
+ }
+}
+
+// TestEntriesIntactRunReturnsNilError is item 2: a complete, clean run -
+// run-start, a step, run-end, nothing corrupt - must read back with a nil
+// error. Every other test in this file needs this to be true along the way,
+// but none of them state it as their own point; this one does.
+func TestEntriesIntactRunReturnsNilError(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "krino.log")
+ w, _ := Open(path)
+ at := time.Date(2026, 9, 12, 8, 0, 0, 0, time.UTC)
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
+ Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := Entries(path, "A")
+ if err != nil {
+ t.Fatalf("a fully intact run returned an error: %v", err)
+ }
+ if len(got) != 3 || got[0].Action != "run-start" || got[1].Action != "move" || got[2].Action != "run-end" {
+ t.Errorf("entries = %+v, want [run-start, move, run-end]", got)
+ }
+}
+
+// TestEntriesBothFailureModesReportsBadLineFirst is item 3: a run that both
+// has an unparsable line inside its window AND lacks a readable run-start
+// must surface as the unparsable-line error, not the missing-run-start one -
+// Entries checks badLine before sawRunStart. The run-start line here is
+// destroyed unattributably (as in TestEntriesFailsClosedOnMissingRunStart),
+// and a second, still-attributable line is separately corrupted so badLine
+// is set via the runFieldOf fallback rather than the window check.
+func TestEntriesBothFailureModesReportsBadLineFirst(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "krino.log")
+ w, _ := Open(path)
+ at := time.Date(2026, 9, 12, 8, 0, 0, 0, time.UTC)
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
+ Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
+ if len(lines) != 3 {
+ t.Fatalf("fixture has %d lines, want 3", len(lines))
+ }
+ // Line 1 (run-start): destroyed unattributably - no tabs at all, so
+ // runFieldOf cannot even recover its Run column.
+ lines[0] = "totally-mangled-no-tabs-here"
+ // Line 2 (move): corrupt its Step column only - it keeps its tabs and
+ // its Run column ("A") stays readable via runFieldOf's fallback.
+ fields := strings.Split(lines[1], "\t")
+ fields[4] = "not-a-number"
+ lines[1] = strings.Join(fields, "\t")
+ if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := Entries(path, "A")
+ if err == nil {
+ t.Fatal("Entries returned no error with both a bad line and a missing run-start present")
+ }
+ if !strings.Contains(err.Error(), "unparsable line 2") {
+ t.Errorf("error = %q, want it to report the unparsable line (line 2), not the missing run-start", err)
+ }
+ if len(got) != 1 || got[0].Action != "run-end" {
+ t.Errorf("entries = %+v, want just the surviving run-end", got)
+ }
+}
+
+// TestEntriesAdjacentRunStartsOneCorrupted is item 4. Ruling R2: this pins
+// what journal.Entries does TODAY for two runs whose run-start lines are
+// adjacent, one of them corrupted - it does not assert an invented "correct"
+// result, and internal/journal is not touched by this task. The log here is
+// exactly:
+//
+// 1 run-start A (good)
+// 2 run-start B (corrupted: no tabs, unattributable)
+// 3 move A (good)
+// 4 run-end A (good)
+// 5 move B (good)
+// 6 run-end B (good)
+//
+// Observed behaviour, traced by hand against Entries and confirmed by this
+// test: Entries(path, "A") fails closed with the unparsable-line error,
+// because line 2 falls inside A's own window (opened by line 1, not yet
+// closed by a run-end) even though the corrupted line was actually B's
+// run-start, not A's - this is exactly the "residual risk... a false
+// refusal, not a false success" the function's own doc comment already
+// names. Entries(path, "B"), in contrast, never sees line 2 as inside its
+// window (B's window has not opened - its own run-start is the corrupted
+// line), so it reaches the end of the file with no badLine, and instead
+// fails on B's missing run-start.
+//
+// Concern (not fixed here, per R2 - flagged for judgement, not code
+// change): the SAME corrupted line produces two different error shapes
+// depending only on which run asks, which is a surprising inconsistency in
+// the message a caller sees, even though both directions correctly fail
+// closed.
+func TestEntriesAdjacentRunStartsOneCorrupted(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "krino.log")
+ w, _ := Open(path)
+ at := time.Date(2026, 9, 12, 8, 0, 0, 0, time.UTC)
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "B", Action: "run-start", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
+ Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "B", Dir: "dl", File: "y.pdf", Step: 1,
+ Action: "move", Status: "ok", Src: "/a/y.pdf", Dst: "/b/y.pdf"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "B", Action: "run-end", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
+ if len(lines) != 6 {
+ t.Fatalf("fixture has %d lines, want 6", len(lines))
+ }
+ // Line 2, B's run-start, adjacent to A's on line 1: destroyed
+ // unattributably.
+ lines[1] = "totally-mangled-no-tabs-here"
+ if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ gotA, errA := Entries(path, "A")
+ if errA == nil {
+ t.Fatal("Entries(A) returned no error; pinned behaviour expects one (the corrupted adjacent line falls inside A's window)")
+ }
+ if !strings.Contains(errA.Error(), "unparsable line 2") {
+ t.Errorf("Entries(A) error = %q, want it to name the unparsable line 2", errA)
+ }
+ if len(gotA) != 3 || gotA[0].Action != "run-start" || gotA[1].Action != "move" || gotA[2].Action != "run-end" {
+ t.Errorf("Entries(A) = %+v, want A's own [run-start, move, run-end]", gotA)
+ }
+
+ gotB, errB := Entries(path, "B")
+ if errB == nil {
+ t.Fatal("Entries(B) returned no error; pinned behaviour expects one (B's own run-start is the corrupted line)")
+ }
+ if !strings.Contains(errB.Error(), "no readable run-start") {
+ t.Errorf("Entries(B) error = %q, want it to name the missing run-start", errB)
+ }
+ if len(gotB) != 2 || gotB[0].Action != "move" || gotB[1].Action != "run-end" {
+ t.Errorf("Entries(B) = %+v, want B's surviving [move, run-end]", gotB)
+ }
+}
+
// TestEntriesReportsAMangledLine: a corrupt line that is not the log's
// final line must not be silently dropped by Entries the way Runs drops it
// - PlanUndo needs to know a step went missing so it can refuse the whole