Go 563 lines
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<!-- eeco:archive:start -->\nold content\n<!-- eeco:archive:end -->\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 := "<!-- archived from doc.md -->\n<!-- eeco:archive:start -->\nold content\n<!-- eeco:archive:end -->\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<!-- eeco:archive:start -->\nA1\n<!-- eeco:archive:end -->\nb\n<!-- eeco:archive:start -->\nB1\nB2\n<!-- eeco:archive:end -->\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, "<!-- archived from doc.md -->") != 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, "<!-- eeco:archive:start -->\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<!-- eeco:archive:end -->\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, "<!-- eeco:archive:start -->\n<!-- eeco:archive:start -->\nx\n<!-- eeco:archive:end -->\n<!-- eeco:archive:end -->\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<!-- eeco:archive:start -->\nnot a real marker\n<!-- eeco:archive:end -->\n```\n\ntrue marker here:\n<!-- eeco:archive:start -->\nreal old block\n<!-- eeco:archive:end -->\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, "<!-- eeco:archive:start -->\nfresh\n<!-- eeco:archive:end -->\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, "<!-- archived from doc.md -->") {
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<!-- eeco:archive:start -->\nbody\n<!-- eeco:archive:end -->\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, "<!-- eeco:archive:start -->\nold\n<!-- eeco:archive:end -->\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<!-- eeco:archive:start -->\r\nbody\r\n<!-- eeco:archive:end -->\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, "<!-- eeco:archive:start -->\nbody\n<!-- eeco:archive:end -->\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", 2, []CompactRegion{{StartLine: 9, EndLine: 11}}},
// keep 1 → the two oldest merge into one contiguous run.
{"keep<count-merge", 1, []CompactRegion{{StartLine: 6, EndLine: 11}}},
// keep 0 → all three sections merge into one run; live tail excluded.
{"keep=0", 0, []CompactRegion{{StartLine: 3, EndLine: 11}}},
// keep == count → nothing to archive.
{"keep==count", 3, nil},
// 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<!-- eeco:archive:start -->\nx\n<!-- eeco:archive:end -->\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<!-- eeco:archive:start -->\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, "<!-- archived from RESUME.md -->") {
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<!-- eeco:archive:start -->\nx\n<!-- eeco:archive:end -->\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)
}
}