ajhahn.de
← eeco
Go 222 lines
package memory

import (
	"bytes"
	"errors"
	"fmt"
	"sort"
	"strings"
	"time"
)

const (
	fmDelim = "---"
)

// ParseFact reads a fact file's raw bytes and returns the parsed Fact.
// The file must begin with a `---` line, contain a single-line
// `key: value` frontmatter block (blank lines and `#` comments allowed),
// be terminated by another `---` line, and may be followed by an
// arbitrary body. ParseFact validates required fields before returning.
func ParseFact(content []byte) (*Fact, error) {
	lines := splitLines(string(content))
	if len(lines) == 0 || strings.TrimSpace(lines[0]) != fmDelim {
		return nil, errors.New("frontmatter: missing opening '---'")
	}
	endIdx := -1
	for i := 1; i < len(lines); i++ {
		if strings.TrimSpace(lines[i]) == fmDelim {
			endIdx = i
			break
		}
	}
	if endIdx < 0 {
		return nil, errors.New("frontmatter: missing closing '---'")
	}

	f := &Fact{}
	for i := 1; i < endIdx; i++ {
		line := lines[i]
		trimmed := strings.TrimSpace(line)
		if trimmed == "" || strings.HasPrefix(trimmed, "#") {
			continue
		}
		key, value, ok := strings.Cut(trimmed, ":")
		if !ok {
			return nil, fmt.Errorf("frontmatter line %d: missing ':'", i+1)
		}
		key = strings.TrimSpace(key)
		val := unquoteFM(strings.TrimSpace(value))
		if err := setField(f, key, val); err != nil {
			return nil, fmt.Errorf("frontmatter line %d: %w", i+1, err)
		}
	}

	bodyLines := lines[endIdx+1:]
	body := strings.Join(bodyLines, "\n")
	body = strings.TrimLeft(body, "\n")
	body = strings.TrimRight(body, " \t\r\n") + "\n"
	if strings.TrimSpace(body) == "" {
		body = ""
	}
	f.Body = body

	if err := f.Validate(); err != nil {
		return nil, fmt.Errorf("frontmatter: %w", err)
	}
	return f, nil
}

func setField(f *Fact, key, val string) error {
	switch key {
	case "name":
		f.Name = val
	case "description":
		f.Description = val
	case "type":
		f.Type = FactType(val)
	case "created":
		t, err := time.Parse(DateLayout, val)
		if err != nil {
			return fmt.Errorf("created: %w", err)
		}
		f.Created = t
	case "last_used":
		t, err := time.Parse(DateLayout, val)
		if err != nil {
			return fmt.Errorf("last_used: %w", err)
		}
		f.LastUsed = t
	case "ref":
		f.Ref = val
	case "expires":
		if val == "" {
			f.Expires = nil
			return nil
		}
		t, err := time.Parse(DateLayout, val)
		if err != nil {
			return fmt.Errorf("expires: %w", err)
		}
		f.Expires = &t
	case "status":
		f.Status = val
	case "pin":
		switch val {
		case "true":
			f.Pin = true
		case "false", "":
			f.Pin = false
		default:
			return fmt.Errorf("pin: must be true or false (got %q)", val)
		}
	case "source":
		f.Source = val
	case "agent":
		f.Agent = val
	case "disabled":
		switch val {
		case "true":
			f.Disabled = true
		case "false", "":
			f.Disabled = false
		default:
			return fmt.Errorf("disabled: must be true or false (got %q)", val)
		}
	default:
		// Unknown keys tolerated for forward-compatibility.
	}
	return nil
}

// Serialize renders a Fact to disk-ready bytes. Field order is stable:
// name, description, type, created, last_used, then optional fields
// (ref, expires, status) only when set, then pin, then optional
// provenance fields (source, agent) and disabled when true. The body is
// appended after the closing `---` separator with a single blank line.
// The output always ends with a newline.
func Serialize(f *Fact) ([]byte, error) {
	if err := f.Validate(); err != nil {
		return nil, err
	}
	var buf bytes.Buffer
	buf.WriteString(fmDelim)
	buf.WriteByte('\n')
	writeKV(&buf, "name", f.Name)
	writeKV(&buf, "description", f.Description)
	writeKV(&buf, "type", string(f.Type))
	writeKV(&buf, "created", f.Created.UTC().Format(DateLayout))
	writeKV(&buf, "last_used", f.LastUsed.UTC().Format(DateLayout))
	if f.Ref != "" {
		writeKV(&buf, "ref", f.Ref)
	}
	if f.Expires != nil {
		writeKV(&buf, "expires", f.Expires.UTC().Format(DateLayout))
	}
	if f.Status != "" {
		writeKV(&buf, "status", f.Status)
	}
	if f.Pin {
		writeKV(&buf, "pin", "true")
	} else {
		writeKV(&buf, "pin", "false")
	}
	if f.Source != "" {
		writeKV(&buf, "source", f.Source)
	}
	if f.Agent != "" {
		writeKV(&buf, "agent", f.Agent)
	}
	if f.Disabled {
		writeKV(&buf, "disabled", "true")
	}
	buf.WriteString(fmDelim)
	buf.WriteByte('\n')
	if f.Body != "" {
		buf.WriteByte('\n')
		body := strings.TrimRight(f.Body, "\n")
		buf.WriteString(body)
		buf.WriteByte('\n')
	}
	return buf.Bytes(), nil
}

func writeKV(buf *bytes.Buffer, k, v string) {
	buf.WriteString(k)
	buf.WriteString(": ")
	buf.WriteString(v)
	buf.WriteByte('\n')
}

// splitLines splits on `\n` and strips trailing `\r` so files written
// with CRLF round-trip correctly.
func splitLines(s string) []string {
	lines := strings.Split(s, "\n")
	for i, line := range lines {
		lines[i] = strings.TrimRight(line, "\r")
	}
	return lines
}

// unquoteFM strips matching surrounding single or double quotes. The
// memory store does not interpret escape sequences inside quoted
// strings; values that need quoting should be one-liners.
func unquoteFM(s string) string {
	if len(s) >= 2 {
		first, last := s[0], s[len(s)-1]
		if (first == '"' || first == '\'') && first == last {
			return s[1 : len(s)-1]
		}
	}
	return s
}

// sortedFacts returns a copy of facts ordered by name. Used by the
// index and by tests that need a deterministic iteration order.
func sortedFacts(facts []*Fact) []*Fact {
	out := make([]*Fact, len(facts))
	copy(out, facts)
	sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
	return out
}