// SPDX-License-Identifier: GPL-3.0-or-later package journal import ( "os" "path/filepath" "strings" "testing" "time" ) func TestRunsListsNewestFirst(t *testing.T) { path := filepath.Join(t.TempDir(), "krino.log") w, _ := Open(path) t0 := time.Date(2026, 9, 11, 9, 0, 0, 0, time.UTC) t1 := t0.Add(time.Hour) for _, r := range []struct { id string at time.Time dirs []string }{{"A", t0, []string{"dl"}}, {"B", t1, []string{"dl", "docs"}}} { w.Append(Entry{Time: r.at, Run: r.id, Action: "run-start", Status: "ok"}) for _, d := range r.dirs { w.Append(Entry{Time: r.at, Run: r.id, Dir: d, File: "x.pdf", Step: 1, Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}) } w.Append(Entry{Time: r.at, Run: r.id, Action: "run-end", Status: "ok"}) } w.Close() runs, err := Runs(path, 0) if err != nil { t.Fatal(err) } if len(runs) != 2 || runs[0].ID != "B" || runs[1].ID != "A" { t.Fatalf("runs = %+v; want B then A", runs) } if len(runs[0].Dirs) != 2 || runs[0].Dirs[0] != "dl" || runs[0].Dirs[1] != "docs" { t.Errorf("run B dirs = %v, want [dl docs] in first-seen order", runs[0].Dirs) } if runs[0].Counts["move"] != 2 { t.Errorf("run B move count = %d, want 2", runs[0].Counts["move"]) } if !runs[0].Start.Equal(t1) { t.Errorf("run B start = %v, want %v", runs[0].Start, t1) } if runs, err = Runs(path, 1); err != nil || len(runs) != 1 || runs[0].ID != "B" { t.Errorf("Runs(path, 1) = %+v, %v", runs, err) } } // TestTruncatedLastLineIsSkipped: a crash mid-write must not make the log // unreadable - everything before the broken line still parses. func TestTruncatedLastLineIsSkipped(t *testing.T) { path := filepath.Join(t.TempDir(), "krino.log") w, _ := Open(path) w.Append(Entry{Time: time.Now(), Run: "A", Action: "run-start", Status: "ok"}) w.Close() f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) f.WriteString("2026-09-11T10:02:03+02:00\tA\tdl\thalf-written") f.Close() runs, err := Runs(path, 0) if err != nil { t.Fatalf("a truncated final line made the whole log unreadable: %v", err) } if len(runs) != 1 || runs[0].ID != "A" { t.Errorf("runs = %+v; want the complete run A", runs) } } // TestRunsMarksAnUndoneRun: an undo run's run-start Detail names the run it // reverses, in the exact format "undo of ". Runs must mark that // earlier run Undone, and must not mark the undo run itself. func TestRunsMarksAnUndoneRun(t *testing.T) { path := filepath.Join(t.TempDir(), "krino.log") w, _ := Open(path) t0 := time.Date(2026, 9, 11, 9, 0, 0, 0, time.UTC) t1 := t0.Add(time.Hour) w.Append(Entry{Time: t0, Run: "A", Action: "run-start", Status: "ok"}) w.Append(Entry{Time: t0, Run: "A", Dir: "dl", File: "x.pdf", Step: 1, Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}) w.Append(Entry{Time: t0, Run: "A", Action: "run-end", Status: "ok"}) w.Append(Entry{Time: t1, Run: "B", Action: "run-start", Status: "ok", Detail: "undo of A"}) w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "x.pdf", Step: 1, Action: "undo-move", Status: "ok", Src: "/b/x.pdf", Dst: "/a/x.pdf"}) w.Append(Entry{Time: t1, Run: "B", Action: "run-end", Status: "ok"}) w.Close() runs, err := Runs(path, 0) if err != nil { t.Fatal(err) } byID := map[string]Run{} for _, r := range runs { byID[r.ID] = r } if !byID["A"].Undone { t.Errorf("run A = %+v, want Undone", byID["A"]) } if byID["B"].Undone { t.Errorf("run B (the undo run itself) = %+v, want not Undone", byID["B"]) } } // TestRunsDoesNotMarkUndoneWhenEveryFileWasDeclined is fix wave item 2 // (Important) / final-wave item 17: an undo run's run-start Detail alone // used to be enough for Runs to mark the original run Undone, even when the // undo run went on to decline every file (spec §9's "declined files are // logged even though nothing happens to them", extended to undo) and // reversed nothing at all. Reproduced by the reviewer via pty: `krino log` // told the user a run had been undone when the file was still filed. Run B // here carries the same run-start Detail as TestRunsMarksAnUndoneRun's, but // every one of its file-scoped entries is "declined", never "ok" - the // shape ApplyUndo logs when the front end's own review declines everything // - so run A must come back exactly as untouched. func TestRunsDoesNotMarkUndoneWhenEveryFileWasDeclined(t *testing.T) { path := filepath.Join(t.TempDir(), "krino.log") w, _ := Open(path) t0 := time.Date(2026, 9, 11, 9, 0, 0, 0, time.UTC) t1 := t0.Add(time.Hour) w.Append(Entry{Time: t0, Run: "A", Action: "run-start", Status: "ok"}) w.Append(Entry{Time: t0, Run: "A", Dir: "dl", File: "x.pdf", Step: 1, Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}) w.Append(Entry{Time: t0, Run: "A", Action: "run-end", Status: "ok"}) w.Append(Entry{Time: t1, Run: "B", Action: "run-start", Status: "ok", Detail: "undo of A"}) w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "x.pdf", Step: 1, Action: "undo-move", Status: "declined", Src: "/b/x.pdf", Dst: "/a/x.pdf"}) w.Append(Entry{Time: t1, Run: "B", Action: "run-end", Status: "ok"}) w.Close() runs, err := Runs(path, 0) if err != nil { t.Fatal(err) } byID := map[string]Run{} for _, r := range runs { byID[r.ID] = r } if byID["A"].Undone { t.Errorf("run A = %+v, want NOT Undone - the undo run declined every file and reversed nothing", byID["A"]) } if byID["B"].Undone { t.Errorf("run B (the undo run itself) = %+v, want not Undone", byID["B"]) } } // TestRunsDoesNotMarkUndoneWhenOnlyOkEntryIsMkdir is the coordinator's // tightening of fix wave item 2: "at least one ok undo-* entry" is still // too loose, by the same shape as the bug it fixes. A file's own chain // stops after a failed file-affecting reversal, but a failed or refused // undo-mkdir deliberately does not stop anything (internal/engine's // isFileAffecting draws exactly this line, and undoFile's stop-on-failure // check shares it) - so an undo-mkdir belonging to one file can still // succeed even though every file-affecting reversal in the whole run // failed. Here x.pdf's own undo-move fails, y.pdf's own undo-move also // fails, and z.pdf's undo-mkdir - tidying up a directory that turned out // empty, not restoring anything - is the run's only "ok" entry. Marking // the original run Undone from that alone would be exactly Important 2's // bug again, by a narrower route. func TestRunsDoesNotMarkUndoneWhenOnlyOkEntryIsMkdir(t *testing.T) { path := filepath.Join(t.TempDir(), "krino.log") w, _ := Open(path) t0 := time.Date(2026, 9, 11, 9, 0, 0, 0, time.UTC) t1 := t0.Add(time.Hour) w.Append(Entry{Time: t0, Run: "A", Action: "run-start", Status: "ok"}) w.Append(Entry{Time: t0, Run: "A", Dir: "dl", File: "x.pdf", Step: 1, Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}) w.Append(Entry{Time: t0, Run: "A", Dir: "dl", File: "y.pdf", Step: 1, Action: "move", Status: "ok", Src: "/a/y.pdf", Dst: "/b/y.pdf"}) w.Append(Entry{Time: t0, Run: "A", Action: "run-end", Status: "ok"}) w.Append(Entry{Time: t1, Run: "B", Action: "run-start", Status: "ok", Detail: "undo of A"}) w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "x.pdf", Step: 1, Action: "undo-move", Status: "failed", Src: "/b/x.pdf", Dst: "/a/x.pdf"}) w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "y.pdf", Step: 1, Action: "undo-move", Status: "failed", Src: "/b/y.pdf", Dst: "/a/y.pdf"}) // z.pdf's own file-affecting reversal is unrelated to x.pdf/y.pdf's // failures; only its cleanup mkdir is shown here, since that mkdir is // the one entry this test is about - the run's only "ok" line. w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "z.pdf", Step: 2, Action: "undo-mkdir", Status: "ok", Src: "/a/Work"}) w.Append(Entry{Time: t1, Run: "B", Action: "run-end", Status: "ok"}) w.Close() runs, err := Runs(path, 0) if err != nil { t.Fatal(err) } byID := map[string]Run{} for _, r := range runs { byID[r.ID] = r } if byID["A"].Undone { t.Errorf("run A = %+v, want NOT Undone - the run's only ok entry is an undo-mkdir (tidiness, not a restoration), and every file-affecting reversal failed", byID["A"]) } } // 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 // run rather than half-undo a file (spec §10). func TestEntriesReportsAMangledLine(t *testing.T) { path := filepath.Join(t.TempDir(), "krino.log") w, _ := Open(path) at := time.Date(2026, 9, 11, 10, 2, 3, 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) } // Mangle line 2 (the move step) in place: corrupt its Step column so it // fails to parse, without touching the line or column count of the // file otherwise - the point is a bad line in the middle, not at EOF. 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)) } fields := strings.Split(lines[1], "\t") fields[4] = "not-a-number" // the step column 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 did not report the mangled line") } if !strings.Contains(err.Error(), "line 2") { t.Errorf("error %q does not name line 2", err) } if len(got) != 2 { t.Fatalf("got %d entries, want the 2 surviving (run-start, run-end): %+v", len(got), got) } if got[0].Action != "run-start" || got[1].Action != "run-end" { t.Errorf("entries = %+v", got) } } // TestEntriesFailsClosedOnUnattributableCorruptionInsideWindow: when a // line's own Run column is destroyed, Entries cannot attribute it by // content - but if it falls inside runID's own window (between its // run-start and run-end), that possibility alone must be enough to refuse // rather than silently return an incomplete chain (spec §10). func TestEntriesFailsClosedOnUnattributableCorruptionInsideWindow(t *testing.T) { path := filepath.Join(t.TempDir(), "krino.log") w, _ := Open(path) at := time.Date(2026, 9, 11, 10, 2, 3, 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) } // Insert a line with no tabs at all - its Run column is unrecoverable - // between the move step and run-end, i.e. inside A's window. 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)) } inserted := make([]string, 0, len(lines)+1) inserted = append(inserted, lines[:2]...) inserted = append(inserted, "totally-mangled-no-tabs-here") inserted = append(inserted, lines[2:]...) if err := os.WriteFile(path, []byte(strings.Join(inserted, "\n")+"\n"), 0o644); err != nil { t.Fatal(err) } got, err := Entries(path, "A") if err == nil { t.Fatal("Entries did not fail closed on unattributable corruption inside the run's window") } if !strings.Contains(err.Error(), "line 3") { t.Errorf("error %q does not name line 3", err) } if len(got) != 3 { t.Fatalf("got %d entries, want the 3 surviving (run-start, move, run-end): %+v", len(got), got) } } // TestEntriesIgnoresUnattributableCorruptionOutsideWindow: the same // corruption shape, placed after A's run-end inside a later run B's own // window, must not poison A - the window scoping keeps it out. func TestEntriesIgnoresUnattributableCorruptionOutsideWindow(t *testing.T) { path := filepath.Join(t.TempDir(), "krino.log") w, _ := Open(path) at := time.Date(2026, 9, 11, 10, 2, 3, 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.Append(Entry{Time: at, Run: "B", Action: "run-start", Status: "ok"}); 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) } // Insert the same unattributable corruption, now inside B's window // (between B's run-start and run-end), not A's. raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") if len(lines) != 5 { t.Fatalf("fixture has %d lines, want 5", len(lines)) } inserted := make([]string, 0, len(lines)+1) inserted = append(inserted, lines[:4]...) inserted = append(inserted, "totally-mangled-no-tabs-here") inserted = append(inserted, lines[4:]...) if err := os.WriteFile(path, []byte(strings.Join(inserted, "\n")+"\n"), 0o644); err != nil { t.Fatal(err) } got, err := Entries(path, "A") if err != nil { t.Fatalf("corruption outside A's window poisoned A: %v", err) } if len(got) != 3 { t.Fatalf("got %d entries, want A's 3: %+v", len(got), got) } } // TestEntriesFailsClosedOnMissingRunStart: run-start is not an optional // marker - every run Apply writes begins with one, so its absence, once // other entries for the run did parse, means either corruption or a log // truncated at the front. Either way the chain cannot be trusted, even // though the window logic alone sees nothing wrong (it never opens without // a parsed run-start, so it never flags anything inside the gap). func TestEntriesFailsClosedOnMissingRunStart(t *testing.T) { path := filepath.Join(t.TempDir(), "krino.log") w, _ := Open(path) at := time.Date(2026, 9, 11, 10, 2, 3, 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) } // Replace A's run-start line (line 1) with a line with no tabs at all - // unrecoverable, like the round-1 fixtures. 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)) } lines[0] = "totally-mangled-no-tabs-here" 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 did not fail closed on a missing run-start") } if !strings.Contains(err.Error(), "run-start") { t.Errorf("error %q does not name the missing run-start", err) } if len(got) != 2 { t.Fatalf("got %d entries, want the 2 surviving (move, run-end): %+v", len(got), got) } if got[0].Action != "move" || got[1].Action != "run-end" { t.Errorf("entries = %+v", got) } }