package docs import ( "os" "path/filepath" "slices" "strings" "testing" ) func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } func readFile(t *testing.T, path string) string { t.Helper() b, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(b) } func TestCompact_NoMarkers_NoOp(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "doc.md") archive := filepath.Join(dir, "doc.archive.md") original := "# Title\n\nNothing marked here.\n" writeFile(t, source, original) rep, err := Compact(source, archive, false) if err != nil { t.Fatalf("Compact: %v", err) } if len(rep.Regions) != 0 { t.Errorf("regions = %v, want none", rep.Regions) } if got := readFile(t, source); got != original { t.Errorf("source mutated:\nwant: %q\ngot: %q", original, got) } if _, err := os.Stat(archive); !os.IsNotExist(err) { t.Errorf("archive should not exist on a no-op run, got err=%v", err) } } func TestCompact_OnePair_MovesAndStubs(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "doc.md") archive := filepath.Join(dir, "doc.archive.md") writeFile(t, source, "# Title\n\nlive line\n\nold content\n\ntail\n") rep, err := Compact(source, archive, false) if err != nil { t.Fatalf("Compact: %v", err) } if len(rep.Regions) != 1 { t.Fatalf("regions = %d, want 1", len(rep.Regions)) } if rep.Regions[0].StartLine != 4 || rep.Regions[0].EndLine != 6 { t.Errorf("region = %+v, want {4,6}", rep.Regions[0]) } wantSource := "# Title\n\nlive line\n> _archived to `doc.archive.md` (eeco docs compact)._\ntail\n" if got := readFile(t, source); got != wantSource { t.Errorf("source:\nwant: %q\ngot: %q", wantSource, got) } wantArchive := "\n\nold content\n\n\n" if got := readFile(t, archive); got != wantArchive { t.Errorf("archive:\nwant: %q\ngot: %q", wantArchive, got) } } func TestCompact_MultiplePairs(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "doc.md") archive := filepath.Join(dir, "doc.archive.md") writeFile(t, source, "a\n\nA1\n\nb\n\nB1\nB2\n\nc\n") rep, err := Compact(source, archive, false) if err != nil { t.Fatalf("Compact: %v", err) } if len(rep.Regions) != 2 { t.Fatalf("regions = %d, want 2", len(rep.Regions)) } gotSource := readFile(t, source) if strings.Count(gotSource, "> _archived to") != 2 { t.Errorf("source should have 2 stub lines:\n%s", gotSource) } if strings.Contains(gotSource, "A1") || strings.Contains(gotSource, "B1") { t.Errorf("source still carries archived body:\n%s", gotSource) } gotArchive := readFile(t, archive) if strings.Count(gotArchive, "") != 2 { t.Errorf("archive should have 2 provenance headers:\n%s", gotArchive) } if !strings.Contains(gotArchive, "A1") || !strings.Contains(gotArchive, "B2") { t.Errorf("archive missing region body:\n%s", gotArchive) } } func TestCompact_UnmatchedStart(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "doc.md") archive := filepath.Join(dir, "doc.archive.md") writeFile(t, source, "\nbody\n") if _, err := Compact(source, archive, false); err == nil { t.Fatal("expected error for unmatched start") } else if !strings.Contains(err.Error(), "no matching end") { t.Errorf("error should name the issue, got %q", err) } if _, err := os.Stat(archive); !os.IsNotExist(err) { t.Errorf("archive should not be created on error, got err=%v", err) } } func TestCompact_UnmatchedEnd(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "doc.md") archive := filepath.Join(dir, "doc.archive.md") writeFile(t, source, "body\n\n") _, err := Compact(source, archive, false) if err == nil { t.Fatal("expected error for unmatched end") } if !strings.Contains(err.Error(), "no matching start") { t.Errorf("error should name the issue, got %q", err) } } func TestCompact_NestedStart(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "doc.md") archive := filepath.Join(dir, "doc.archive.md") writeFile(t, source, "\n\nx\n\n\n") _, err := Compact(source, archive, false) if err == nil { t.Fatal("expected error for nested start") } if !strings.Contains(err.Error(), "nested start") { t.Errorf("error should name the issue, got %q", err) } } func TestCompact_MarkerInsideFenceIgnored(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "doc.md") archive := filepath.Join(dir, "doc.archive.md") original := "show a fenced example:\n\n```\n\nnot a real marker\n\n```\n\ntrue marker here:\n\nreal old block\n\ntail\n" writeFile(t, source, original) rep, err := Compact(source, archive, false) if err != nil { t.Fatalf("Compact: %v", err) } if len(rep.Regions) != 1 { t.Fatalf("regions = %d, want 1 (markers inside fence ignored)", len(rep.Regions)) } gotSource := readFile(t, source) if !strings.Contains(gotSource, "not a real marker") { t.Errorf("source should still carry the fenced example body") } if strings.Contains(gotSource, "real old block") { t.Errorf("source should not carry the moved real block") } } func TestCompact_ArchiveAppends(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "doc.md") archive := filepath.Join(dir, "doc.archive.md") writeFile(t, archive, "prior archive content\n") writeFile(t, source, "\nfresh\n\n") rep, err := Compact(source, archive, false) if err != nil { t.Fatalf("Compact: %v", err) } if !rep.ArchiveExists { t.Error("report should flag archive as pre-existing") } got := readFile(t, archive) if !strings.HasPrefix(got, "prior archive content\n") { t.Errorf("prior content must stay at top: %q", got) } if !strings.Contains(got, "") { t.Errorf("archive missing new header: %q", got) } if !strings.Contains(got, "fresh") { t.Errorf("archive missing new body: %q", got) } } func TestCompact_DryRun(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "doc.md") archive := filepath.Join(dir, "doc.archive.md") original := "head\n\nbody\n\ntail\n" writeFile(t, source, original) rep, err := Compact(source, archive, true) if err != nil { t.Fatalf("Compact dry-run: %v", err) } if !rep.DryRun { t.Error("report should flag dry-run") } if len(rep.Regions) != 1 { t.Errorf("regions = %d, want 1 (dry-run still scans)", len(rep.Regions)) } if got := readFile(t, source); got != original { t.Errorf("dry-run mutated source: %q", got) } if _, err := os.Stat(archive); !os.IsNotExist(err) { t.Errorf("dry-run created archive: err=%v", err) } } func TestCompact_Idempotent(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "doc.md") archive := filepath.Join(dir, "doc.archive.md") writeFile(t, source, "\nold\n\n") if _, err := Compact(source, archive, false); err != nil { t.Fatalf("first Compact: %v", err) } afterFirst := readFile(t, source) archiveAfterFirst := readFile(t, archive) rep, err := Compact(source, archive, false) if err != nil { t.Fatalf("second Compact: %v", err) } if len(rep.Regions) != 0 { t.Errorf("second run should find 0 regions, got %d", len(rep.Regions)) } if got := readFile(t, source); got != afterFirst { t.Errorf("second run mutated source") } if got := readFile(t, archive); got != archiveAfterFirst { t.Errorf("second run mutated archive") } } func TestCompact_CRLFPreserved(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "doc.md") archive := filepath.Join(dir, "doc.archive.md") writeFile(t, source, "head\r\n\r\nbody\r\n\r\ntail\r\n") if _, err := Compact(source, archive, false); err != nil { t.Fatalf("Compact: %v", err) } gotSource := readFile(t, source) if !strings.Contains(gotSource, "\r\n") { t.Errorf("source CRLF lost: %q", gotSource) } if !strings.Contains(gotSource, "(eeco docs compact)._\r\n") { t.Errorf("stub did not use CRLF newline: %q", gotSource) } } func TestCompact_StubReferencesArchivePath(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "sub", "doc.md") archive := filepath.Join(dir, "archives", "doc.archive.md") writeFile(t, source, "\nbody\n\n") if _, err := Compact(source, archive, false); err != nil { t.Fatalf("Compact: %v", err) } got := readFile(t, source) wantPath := "../archives/doc.archive.md" if !strings.Contains(got, wantPath) { t.Errorf("stub should reference archive at %q:\n%s", wantPath, got) } } func TestCompact_SourceMissing(t *testing.T) { dir := t.TempDir() _, err := Compact(filepath.Join(dir, "nope.md"), filepath.Join(dir, "a.md"), false) if err == nil { t.Fatal("expected error reading missing source") } } // --- heading mode (--keep-last) --- func TestHeadingLevel(t *testing.T) { cases := []struct { line string want int }{ {"# Title\n", 1}, {"## Snapshot — session 3\n", 2}, {"### sub\n", 3}, {"###\n", 3}, // hashes then EOL is a heading {"#nospace", 0}, // no space after the run {"plain text", 0}, {" ## indented\n", 2}, // leading whitespace allowed {"", 0}, {"## Snapshot", 2}, // no trailing newline } for _, c := range cases { if got := headingLevel(c.line); got != c.want { t.Errorf("headingLevel(%q) = %d, want %d", c.line, got, c.want) } } } // threeSnapshots is the canonical fixture: newest snapshot on top, a live // "## Next session" tail after the oldest. Line numbers (1-based): // // 1 # Doc // 2 (blank) // 3 ## Snapshot — session 3 // 4 c3 content // 5 (blank) // 6 ## Snapshot — session 2 // 7 c2 content // 8 (blank) // 9 ## Snapshot — session 1 // 10 c1 content // 11 (blank) // 12 ## Next session // 13 live tail const threeSnapshots = "# Doc\n\n" + "## Snapshot — session 3\nc3 content\n\n" + "## Snapshot — session 2\nc2 content\n\n" + "## Snapshot — session 1\nc1 content\n\n" + "## Next session\nlive tail\n" func regionsEqual(a, b []CompactRegion) bool { return slices.Equal(a, b) } func TestScanHeadingRegions_KeepWindow(t *testing.T) { cases := []struct { name string keepLast int want []CompactRegion }{ // keep 2 of 3 → only the oldest section, live tail excluded. {"keep count → nothing to archive. {"keep>count", 5, nil}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got, err := scanHeadingRegions([]byte(threeSnapshots), "## Snapshot", c.keepLast) if err != nil { t.Fatalf("scanHeadingRegions: %v", err) } if !regionsEqual(got, c.want) { t.Errorf("regions = %+v, want %+v", got, c.want) } }) } } func TestScanHeadingRegions_FenceIgnored(t *testing.T) { // A fenced "## Snapshot …" line must not be treated as a heading and // must not split the section that contains it. src := "## Snapshot — session 2\nc2\n\nintro\n```\n## Snapshot — session fake\nnot a heading\n```\nmore c2\n\n" + "## Snapshot — session 1\nc1\n\n## Next session\ntail\n" got, err := scanHeadingRegions([]byte(src), "## Snapshot", 1) if err != nil { t.Fatalf("scanHeadingRegions: %v", err) } // session 1 starts at line 11, "## Next session" is line 14 → region [11,13]. want := []CompactRegion{{StartLine: 11, EndLine: 13}} if !regionsEqual(got, want) { t.Errorf("regions = %+v, want %+v (fenced fake heading should not split)", got, want) } } func TestScanHeadingRegions_DeeperHeadingDoesNotSplit(t *testing.T) { src := "## Snapshot — session 2\nc2\n### sub\ndeeper\nmore\n\n" + "## Snapshot — session 1\nc1\n\n## Next session\ntail\n" got, err := scanHeadingRegions([]byte(src), "## Snapshot", 1) if err != nil { t.Fatalf("scanHeadingRegions: %v", err) } // session 1 starts at line 7, "## Next session" is line 10 → region [7,9]. want := []CompactRegion{{StartLine: 7, EndLine: 9}} if !regionsEqual(got, want) { t.Errorf("regions = %+v, want %+v (### should not split the section)", got, want) } } func TestScanHeadingRegions_NonMatchingSameLevelTerminates(t *testing.T) { // A non-matching same-level "## Interlude" terminates a section and is // itself never archived; non-adjacent archivable sections stay split. src := "## Snapshot — session 2\nc2\n\n## Interlude\nnot a snapshot\n\n" + "## Snapshot — session 1\nc1\n\n## Next session\ntail\n" got, err := scanHeadingRegions([]byte(src), "## Snapshot", 0) if err != nil { t.Fatalf("scanHeadingRegions: %v", err) } // session 2 = [1,3]; "## Interlude" (lines 4-6) stays; session 1 = [7,9]. want := []CompactRegion{{StartLine: 1, EndLine: 3}, {StartLine: 7, EndLine: 9}} if !regionsEqual(got, want) { t.Errorf("regions = %+v, want %+v (interlude must split + survive)", got, want) } } func TestScanHeadingRegions_PrefixNotHeading(t *testing.T) { _, err := scanHeadingRegions([]byte(threeSnapshots), "Snapshot", 1) if err == nil { t.Fatal("expected error for non-heading prefix") } if !strings.Contains(err.Error(), "not a markdown heading") { t.Errorf("error should name the issue, got %q", err) } } func TestScanHeadingRegions_MarkersConflict(t *testing.T) { src := "head\n\nx\n\n## Snapshot — session 1\nc1\n" _, err := scanHeadingRegions([]byte(src), "## Snapshot", 0) if err == nil { t.Fatal("expected error when explicit markers are present in heading mode") } if !strings.Contains(err.Error(), "explicit archive markers") { t.Errorf("error should name the conflict, got %q", err) } } func TestScanHeadingRegions_MalformedMarkersConflict(t *testing.T) { // An unmatched start marker (no end) also signals marker-mode intent; // heading mode refuses rather than letting the stray marker survive. src := "head\n\nx\n## Snapshot — session 1\nc1\n" _, err := scanHeadingRegions([]byte(src), "## Snapshot", 0) if err == nil { t.Fatal("expected error for malformed (unmatched) markers in heading mode") } if !strings.Contains(err.Error(), "explicit archive markers") { t.Errorf("error should name the conflict, got %q", err) } } func TestCompactKeepLast_MovesOldest(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "RESUME.md") archive := filepath.Join(dir, "RESUME.archive.md") writeFile(t, source, threeSnapshots) rep, err := CompactKeepLast(source, archive, false, "## Snapshot", 2) if err != nil { t.Fatalf("CompactKeepLast: %v", err) } if len(rep.Regions) != 1 { t.Fatalf("regions = %d, want 1", len(rep.Regions)) } gotSource := readFile(t, source) for _, keep := range []string{"## Snapshot — session 3", "## Snapshot — session 2", "## Next session", "live tail"} { if !strings.Contains(gotSource, keep) { t.Errorf("source dropped a kept block %q:\n%s", keep, gotSource) } } for _, gone := range []string{"## Snapshot — session 1", "c1 content"} { if strings.Contains(gotSource, gone) { t.Errorf("source still carries archived block %q:\n%s", gone, gotSource) } } if n := strings.Count(gotSource, "> _archived to"); n != 1 { t.Errorf("source should have exactly one stub, got %d:\n%s", n, gotSource) } gotArchive := readFile(t, archive) if !strings.Contains(gotArchive, "## Snapshot — session 1") || !strings.Contains(gotArchive, "c1 content") { t.Errorf("archive missing the moved oldest section:\n%s", gotArchive) } if !strings.Contains(gotArchive, "") { t.Errorf("archive missing provenance header:\n%s", gotArchive) } } func TestCompactKeepLast_Idempotent(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "RESUME.md") archive := filepath.Join(dir, "RESUME.archive.md") writeFile(t, source, threeSnapshots) if _, err := CompactKeepLast(source, archive, false, "## Snapshot", 2); err != nil { t.Fatalf("first run: %v", err) } afterFirst := readFile(t, source) archiveAfterFirst := readFile(t, archive) rep, err := CompactKeepLast(source, archive, false, "## Snapshot", 2) if err != nil { t.Fatalf("second run: %v", err) } if len(rep.Regions) != 0 { t.Errorf("second run should find 0 regions (only 2 sections remain, keep 2), got %d", len(rep.Regions)) } if readFile(t, source) != afterFirst { t.Error("second run mutated source") } if readFile(t, archive) != archiveAfterFirst { t.Error("second run mutated archive") } } func TestCompactKeepLast_DryRun(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "RESUME.md") archive := filepath.Join(dir, "RESUME.archive.md") writeFile(t, source, threeSnapshots) rep, err := CompactKeepLast(source, archive, true, "## Snapshot", 2) if err != nil { t.Fatalf("CompactKeepLast dry-run: %v", err) } if len(rep.Regions) != 1 { t.Errorf("dry-run regions = %d, want 1 (still scans)", len(rep.Regions)) } if readFile(t, source) != threeSnapshots { t.Error("dry-run mutated source") } if _, err := os.Stat(archive); !os.IsNotExist(err) { t.Errorf("dry-run created archive: err=%v", err) } } func TestCompactKeepLast_MarkersConflict(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "RESUME.md") archive := filepath.Join(dir, "RESUME.archive.md") writeFile(t, source, "head\n\nx\n\n## Snapshot — s1\nc1\n") _, err := CompactKeepLast(source, archive, false, "## Snapshot", 0) if err == nil { t.Fatal("expected error when markers present in heading mode") } if !strings.Contains(err.Error(), "explicit archive markers") { t.Errorf("error should name the conflict, got %q", err) } if _, err := os.Stat(archive); !os.IsNotExist(err) { t.Errorf("conflict run should not create archive: err=%v", err) } }