ajhahn.de
← eeco
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)
	}
}