ajhahn.de
← eeco
Go 222 lines
package docs

import (
	"bytes"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

// Marker spellings for `eeco docs refresh`. Mirrors the `archive` and
// `session` schemes; HTML-comment so a Markdown renderer hides them while
// still treating the body between as content. Fixed in slice 1; a future
// slice can introduce a config knob if a user needs custom markers.
const (
	docsStartMarker = "<!-- eeco:docs:start -->"
	docsEndMarker   = "<!-- eeco:docs:end -->"
)

// RefreshAction names the change Refresh made to the target file.
type RefreshAction string

const (
	// RefreshReplaced means the existing in-place docs block was rewritten.
	RefreshReplaced RefreshAction = "replaced"
	// RefreshAppended means no marker pair was present; a freshly-rendered
	// block was appended at EOF. The operator is expected to remove the
	// prior in-place content manually.
	RefreshAppended RefreshAction = "appended"
	// RefreshNoop means the file's bytes did not change (rendered block
	// already matched).
	RefreshNoop RefreshAction = "noop"
)

// RefreshReport summarises a refresh run.
type RefreshReport struct {
	Path   string
	Action RefreshAction
}

// Refresh re-renders the project-state-derived block of target's file at
// repoRoot/<target-filename>, leaving operator prose outside the marker
// pair untouched. The marker pair is `<!-- eeco:docs:start -->` /
// `<!-- eeco:docs:end -->`, fence-aware like `eeco docs compact`.
//
// Behaviour:
//   - Marker pair found: bytes between the markers are replaced with the
//     freshly-rendered block extracted from the template.
//   - Marker pair absent (legacy scaffold): the freshly-rendered block,
//     marker-wrapped, is appended at EOF with a blank-line separator.
//     The operator removes the prior in-place block manually.
//   - File missing: refuse with a hint pointing to `eeco docs new`.
//   - Malformed markers (unmatched, nested, out-of-order): refuse with a
//     parse error naming the offending line; the file is not touched.
//
// eeco writes the file but never stages or commits it (Constraint 6).
func Refresh(target Target, repoRoot string, p Params) (RefreshReport, error) {
	name := target.Filename()
	if name == "" {
		return RefreshReport{}, fmt.Errorf("unknown target %q", string(target))
	}
	if filepath.IsAbs(name) || filepath.Clean(name) != name {
		return RefreshReport{}, fmt.Errorf("target filename %q is not a safe relative path", name)
	}
	full := filepath.Join(repoRoot, name)

	existing, err := os.ReadFile(full)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return RefreshReport{Path: name}, fmt.Errorf("%s does not exist; run `eeco docs new %s` first", name, string(target))
		}
		return RefreshReport{Path: name}, err
	}

	rendered, err := Render(target, p)
	if err != nil {
		return RefreshReport{Path: name}, err
	}
	renderedBlock, err := extractDocsBlock([]byte(rendered))
	if err != nil {
		return RefreshReport{Path: name}, fmt.Errorf("template for %s has no docs marker pair — refresh cannot operate", string(target))
	}

	startByte, endByte, found, err := findDocsBlock(existing)
	if err != nil {
		return RefreshReport{Path: name}, err
	}

	lines := splitLinesKeepEOL(existing)
	newline := dominantNewline(lines)

	var out []byte
	var action RefreshAction
	if found {
		var buf bytes.Buffer
		buf.Write(existing[:startByte])
		buf.WriteString(docsStartMarker)
		buf.WriteString(newline)
		buf.Write(renderedBlock)
		if len(renderedBlock) > 0 && !bytes.HasSuffix(renderedBlock, []byte("\n")) {
			buf.WriteString(newline)
		}
		buf.WriteString(docsEndMarker)
		buf.WriteString(newline)
		buf.Write(existing[endByte:])
		out = buf.Bytes()
		action = RefreshReplaced
	} else {
		var buf bytes.Buffer
		buf.Write(existing)
		if len(existing) > 0 {
			if !bytes.HasSuffix(existing, []byte("\n")) {
				buf.WriteString(newline)
			}
			buf.WriteString(newline)
		}
		buf.WriteString(docsStartMarker)
		buf.WriteString(newline)
		buf.Write(renderedBlock)
		if len(renderedBlock) > 0 && !bytes.HasSuffix(renderedBlock, []byte("\n")) {
			buf.WriteString(newline)
		}
		buf.WriteString(docsEndMarker)
		buf.WriteString(newline)
		out = buf.Bytes()
		action = RefreshAppended
	}

	if bytes.Equal(out, existing) {
		return RefreshReport{Path: name, Action: RefreshNoop}, nil
	}
	if err := os.WriteFile(full, out, 0o644); err != nil {
		return RefreshReport{Path: name}, err
	}
	return RefreshReport{Path: name, Action: action}, nil
}

// extractDocsBlock returns the bytes between the docs markers in src,
// exclusive of the marker lines themselves. Returns an error when the
// marker pair is missing or malformed.
func extractDocsBlock(src []byte) ([]byte, error) {
	startByte, endByte, found, err := findDocsBlock(src)
	if err != nil {
		return nil, err
	}
	if !found {
		return nil, fmt.Errorf("no docs markers")
	}
	// Body begins at the byte after the start-marker line's newline.
	startTail := src[startByte:]
	startNL := bytes.IndexByte(startTail, '\n')
	if startNL < 0 {
		return []byte{}, nil
	}
	bodyStart := startByte + startNL + 1
	// Body ends at the newline that terminates the last body line, which
	// is the previous newline before the end-marker line. Search inside
	// the block, walking back from one byte before its end so the
	// end-marker line's own trailing newline (if any) is not picked up.
	if endByte <= bodyStart {
		return []byte{}, nil
	}
	prevNL := bytes.LastIndexByte(src[bodyStart:endByte-1], '\n')
	if prevNL < 0 {
		return []byte{}, nil
	}
	bodyEnd := bodyStart + prevNL + 1
	return src[bodyStart:bodyEnd], nil
}

// findDocsBlock walks src once and returns the byte offsets of the
// start-marker line and the end-marker line for the single eeco docs
// block. Markers inside fenced code blocks are ignored. found=false when
// no markers (or only one) are present at top level. An unmatched,
// nested, or out-of-order marker pair is reported as an error so the
// caller refuses to write rather than guess. Mirrors
// `internal/hooks/sessiondelivery.go:findSessionBlock` byte-for-byte in
// posture; only the marker spellings differ.
func findDocsBlock(src []byte) (startByte, endByte int, found bool, err error) {
	lines := splitLinesKeepEOL(src)
	inFence := false
	startIdx, endIdx := -1, -1
	for i, line := range lines {
		trimmed := strings.TrimRight(line, "\r\n")
		stripped := strings.TrimLeft(trimmed, " \t")
		if strings.HasPrefix(stripped, "```") || strings.HasPrefix(stripped, "~~~") {
			inFence = !inFence
			continue
		}
		if inFence {
			continue
		}
		marker := strings.TrimSpace(trimmed)
		switch marker {
		case docsStartMarker:
			if startIdx != -1 {
				return 0, 0, false, fmt.Errorf("docs line %d: nested start marker (previous still open at line %d)", i+1, startIdx+1)
			}
			startIdx = i
		case docsEndMarker:
			if startIdx == -1 {
				return 0, 0, false, fmt.Errorf("docs line %d: end marker with no matching start", i+1)
			}
			endIdx = i
			startByte = 0
			for j := 0; j < startIdx; j++ {
				startByte += len(lines[j])
			}
			endByte = startByte
			for j := startIdx; j <= endIdx; j++ {
				endByte += len(lines[j])
			}
			return startByte, endByte, true, nil
		}
	}
	if startIdx != -1 {
		return 0, 0, false, fmt.Errorf("docs line %d: start marker with no matching end", startIdx+1)
	}
	return 0, 0, false, nil
}