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
}