// SPDX-License-Identifier: GPL-3.0-or-later package journal import ( "os" "path/filepath" "strings" "testing" "time" ) func TestNewRunID(t *testing.T) { at := time.Date(2026, 9, 11, 10, 2, 3, 0, time.UTC) id := NewRunID(at) if !strings.HasPrefix(id, "20260911T100203-") || len(id) != len("20260911T100203-")+4 { t.Fatalf("run id = %q", id) } if NewRunID(at) == id { t.Error("two run ids for the same instant collided; the suffix is not random") } } // TestRoundTripsAwkwardNames is the point of the escaping: a file name with a // tab, a newline, a backslash, a control byte or invalid UTF-8 must come back // byte-for-byte, and must not break the line or column structure awk sees. func TestRoundTripsAwkwardNames(t *testing.T) { names := []string{ "plain.pdf", "with\ttab.pdf", "with\nnewline.pdf", "back\\slash.pdf", "bell\a.pdf", "invalid\xff\xfeutf8.pdf", "zażółć gęślą jaźń.pdf", } path := filepath.Join(t.TempDir(), "state", "krino.log") w, err := Open(path) if err != nil { t.Fatal(err) } at := time.Date(2026, 9, 11, 10, 2, 3, 0, time.FixedZone("CEST", 2*3600)) if err := w.Append(Entry{Time: at, Run: "R", Action: "run-start", Status: "ok"}); err != nil { t.Fatal(err) } for i, n := range names { e := Entry{Time: at, Run: "R", Dir: "dl", File: n, Step: i + 1, Action: "move", Status: "ok", Rule: "acme", Src: "/a/" + n, Dst: "/b/" + n, Size: int64(i), ModTime: at, Detail: ""} if err := w.Append(e); 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) } if got := strings.Count(string(raw), "\n"); got != len(names)+1 { t.Errorf("file has %d lines, want %d: a field broke the line structure", got, len(names)+1) } for _, line := range strings.Split(strings.TrimRight(string(raw), "\n"), "\n") { if n := strings.Count(line, "\t"); n != 12 { t.Errorf("line has %d tabs, want 12 (13 columns): %q", n, line) } } got, err := Entries(path, "R") if err != nil { t.Fatal(err) } if len(got) != len(names)+1 { t.Fatalf("read %d entries, want %d", len(got), len(names)+1) } for i, n := range names { if got[i+1].File != n { t.Errorf("entry %d: File = %q, want %q", i, got[i+1].File, n) } if got[i+1].Src != "/a/"+n || got[i+1].Dst != "/b/"+n { t.Errorf("entry %d: paths did not round-trip: %q %q", i, got[i+1].Src, got[i+1].Dst) } if !got[i+1].Time.Equal(at) { t.Errorf("entry %d: Time = %v, want %v", i, got[i+1].Time, at) } } } // TestAppendWritesRFC3339WithOffsetAndKeepsNanoseconds is item 13, promoted // to before-commit by the plan 4 final review: nothing anywhere pinned the // journal's on-disk time format - RFC3339Nano appears in no test file, and // every timestamp assertion round-trips through krino's own Writer and // Entries, so a change to something no other tool could parse would pass // silently. The journal is the only record undo has. This reads the RAW // bytes of a written line - not Entries, which would launder the format // through krino's own parser - and asserts column 1 parses as RFC 3339 with // a real numeric offset (not just "Z"), and that a time carrying // nanoseconds keeps them. func TestAppendWritesRFC3339WithOffsetAndKeepsNanoseconds(t *testing.T) { path := filepath.Join(t.TempDir(), "state", "krino.log") w, err := Open(path) if err != nil { t.Fatal(err) } at := time.Date(2026, 9, 11, 10, 2, 3, 123456789, time.FixedZone("", 2*3600)) if err := w.Append(Entry{Time: at, Run: "R", Action: "run-start", 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) } line := strings.TrimSuffix(string(raw), "\n") col1 := strings.SplitN(line, "\t", 2)[0] parsed, err := time.Parse(time.RFC3339, col1) if err != nil { t.Fatalf("column 1 %q does not parse as RFC 3339: %v", col1, err) } if _, offset := parsed.Zone(); offset != 2*3600 { t.Errorf("offset = %ds, want %ds: the written column must carry a real offset, not just a bare local time", offset, 2*3600) } if parsed.Nanosecond() != 123456789 { t.Errorf("nanoseconds = %d, want 123456789: a nanosecond-precision time must not be truncated on the wire", parsed.Nanosecond()) } } func TestAppendIsAppendOnly(t *testing.T) { path := filepath.Join(t.TempDir(), "krino.log") for i := 0; i < 2; i++ { w, err := Open(path) if err != nil { t.Fatal(err) } if err := w.Append(Entry{Time: time.Now(), Run: "R", Action: "run-start", Status: "ok"}); err != nil { t.Fatal(err) } w.Close() } raw, _ := os.ReadFile(path) if got := strings.Count(string(raw), "\n"); got != 2 { t.Errorf("%d lines after two Open/Append/Close cycles, want 2: the second Open truncated", got) } } // TestOpenRepairsAMissingFinalNewline: a crash can leave the log's last // line without its newline; the next run's first line must not be glued // onto it, or that run could never be undone (review M8). func TestOpenRepairsAMissingFinalNewline(t *testing.T) { path := filepath.Join(t.TempDir(), "krino.log") if err := os.WriteFile(path, []byte("2026-09-11T10:02:03+02:00\tR0\tdl\ta.pdf\t1\tmo"), 0o644); err != nil { t.Fatal(err) } w, err := Open(path) if err != nil { t.Fatal(err) } at := time.Date(2026, 9, 11, 10, 5, 0, 0, time.UTC) for _, e := range []Entry{{Time: at, Run: "R1", Action: "run-start", Status: "ok"}, {Time: at, Run: "R1", Action: "run-end", Status: "ok"}} { if err := w.Append(e); err != nil { t.Fatal(err) } } if err := w.Close(); err != nil { t.Fatal(err) } entries, err := Entries(path, "R1") if err != nil || len(entries) != 2 { t.Fatalf("Entries(R1) = %d entries, %v; want run-start and run-end", len(entries), err) } }